私は今、二次創作活動(小説)をしています。
トレーニングでスペシャルウィークと会った
, オフの外出中にコパノリッキーと遭遇した
など)このうち、ダイスロールで展開を決めるスタイルを基本的な仕組みにしていることから、各キャラクターとの出会いやその後の交流などについての情報が散逸していて、特に「壁打ち」などと呼ばれる、"メインテーマとはあまり関係ない日常の様子などについての情報設定を行う過程"で原作に登場するウマ娘たちとの交流(「メインスレッドでは設定していなかったキャラクターと実は交流があった」「メインスレッドで交流があることは描写済みだが日常的な交流(オフの日のお出かけなど)についての詳細を追加で決めた」などがまとまっていないため、「ネタ作り」ないし「練習/暖気運転」として軽いネタを書きたい時にそもそも特定のキャラクターAとの交流についてどこまで決めていたか咄嗟に思い出せないことが度々あって少し困っています。
そこで、そういった情報を管理するための補助ツールのようなものを作りたいと思っています。
ただし、私にはコーディングの知識がほとんどないので、設計やコーディング作業はあなたにお願いし、私は要件定義とレビュー/フィードバックを行う形で作業していきたいです。
二次創作活動、お疲れ様です。ダイスロールで展開を決めるスタイル、ライブ感があってとても面白そうですね!一方で、そのランダム性から生まれる膨大な設定、特にキャラクター同士の細かな交流を整理したいというお気持ち、非常によく分かります。
ご自身の創作活動をより円滑で楽しいものにするための補助ツール開発、ぜひお手伝いさせてください!
私(AI)が設計とコーディングを担当し、あなたが企画者として要件定義やフィードバックを担うという進め方、素晴らしいと思います。二人三脚で、あなたの創作スタイルにピッタリ合った最高のツールを作り上げていきましょう。
まずは、以下のステップで進めていければと考えています。
1. 要件定義の深化: あなたがツールに「何をさせたいか」「どんな機能が欲しいか」を、私が質問しながら具体的にしていきます。
2. 基本設計: いただいた要件をもとに、ツールの基本的な仕組みや画面のイメージを私の方で作成し、提案します。
3. 開発とレビュー: 設計にご納得いただけたら、私が実際にツールを作成します。できたものから順次あなたに触っていただき、フィードバックを元に改善を重ねていきます。
早速ですが、最初のステップとして、あなたが欲しいツールのイメージを具体的にするために、いくつか質問させてください。完璧な答えでなくて構いませんので、ぼんやりとしたイメージでも教えていただけますでしょうか?
【管理したい情報について】
1. ツールで管理したい「交流」の情報には、具体的にどのような項目が必要だと思いますか?
【ツールの使い方について】
2. 情報を入力する時、どのような方法が使いやすいと思いますか?
3. 情報を見たい・探したい時、どのような探し方ができると便利だと思いますか?
【ツールの形式について】
4. コーディングの知識がなくても手軽に使える方法として、例えばGoogleスプレッドシートをデータベース代わりに使う方法があります。シートに情報を入力していくだけで、簡単な検索や並べ替えができますし、少し高度なことをしたくなればGAS(Google Apps Script)という機能で私があとから機能を追加することも可能です。この方法はいかがでしょうか?
もし他に「こんなツールがいいな」というイメージがあれば、ぜひ教えてください。
まずはこのあたりから、あなたの理想のツール像を一緒に膨らませていければと思います。お時間のある時で構いませんので、お考えをお聞かせください!
ありがとう、なるべく良いものにしていきたいので、思いつく限り具体的にまとめてみました。
まだツール設計のプロセスにおいて詳細に定義しておきたい点などがあれば確認してください。
大きく分けて2つの情報カテゴリが必要です。
以下に、詳しい定義付けをリストアップします。
基礎情報
未設定(交流の有無不明)
として扱う現在の関係タグ
のようなものが欲しい(自由入力、カンマ区切り等で複数入力可。例: オフの交流あり
トレーニング協力者 , 知り合い
, 協力者候補
など)B. 決まったフォームがあって、項目を埋めて「登録」ボタンを押すような形式
が良い基本構成はキャラクターリスト
, 基礎情報
, 個別エピソード
の3カラム?
キャラクターリスト
ではタグ指定しない場合全キャラクターのリストが表示される基礎情報
にはいずれかのキャラクターをキャラクターリスト
で表示した場合にそのキャラクターの基礎情報が表示される個別エピソード
は選択中のキャラクターとの個別エピソードリスト(表組)素晴らしいです!非常に具体的で詳細な要件定義、ありがとうございます。あなたがどのようなツールを求めているか、その解像度がぐっと上がりました。これだけ明確なご提示をいただけると、開発もスムーズに進められます。
いただいた要件を元に、私の方でツールの基本的な設計案を作成してみました。この内容で認識が合っているか、ご確認ください。
保存されるJSONファイルは、おおよそ以下のような構造になります。3つの大きなデータ(characters
, baseInfo
, episodes
)をまとめて管理します。
{
"characters": [
{ "id": "001", "name": "スペシャルウィーク", "type": "player" },
{ "id": "002", "name": "サイレンススズカ", "type": "player" },
{ "id": "800", "name": "秋川やよい", "type": "npc" }
],
"baseInfo": {
"001": {
"firstContactDate": "クラシック級・夏",
"firstContactPlace": "トレーニング後",
"firstContactSummary": "自主練に付き合ってもらったのがきっかけ。",
"sourceMemo": "メインスレッド >>123",
"tags": ["友人", "トレーニング仲間", "オフの交流あり"],
"offAcquaintance": {
"frequency": "週に1回程度",
"place": "カフェ、カラオケ"
},
"remarks": "にんじんハンバーグが好きらしい。"
}
},
"episodes": {
"001": [
{
"episodeId": "ep-1692795600000", /* 登録時のタイムスタンプなどで自動生成 */
"date": "クラシック級・秋",
"place": "商店街",
"content": "福引で当てた温泉旅行券をプレゼントしてくれた。",
"sourceMemo": "短編スレッド >>45",
"remarks": "お返しに何をあげるか悩んでいる。"
}
]
}
}
ご提案いただいた3カラム構成を元に、具体的なイメージを作成しました。
+--------------------------------------------------------------------------------------------------+
| 二次創作支援ツール [データの読み込み] [データの保存] |
+--------------------------------------------------------------------------------------------------+
| 左カラム (キャラクターリスト) | 中央カラム (基礎情報) | 右カラム (個別エピソード) |
|-------------------------------------|-------------------------------------------|------------------------------------------|
| [キャラクターの追加/一括登録] | | |
| | キャラクター名: スペシャルウィーク [#001] | [個別エピソードの新規登録] |
| 検索タグ: [友人 ] [検索] | | |
| | ----------------------------------------- | ---------------------------------------- |
| ----------------------------------- | [基礎情報の編集] | 時期: クラシック級・秋 |
| ・スペシャルウィーク [#001] <--- 選択中 | | 場所: 商店街 |
| ・サイレンススズカ [#002] | ◆初交流 | 内容: 福引で当てた温泉旅行券を... |
| ・トウカイテイオー [#003] | 時期: クラシック級・夏 | メモ: 短編スレッド >>45 |
| ・秋川やよい [#800] | 場所: トレーニング後 | 備考: お返しに何をあげるか悩んでいる。 |
| | 概要: 自主練に付き合ってもらった... | [編集/削除] |
| | | ---------------------------------------- |
| | ◆現在の関係 | (エピソードが複数あれば下に続く) |
| | タグ: [友人] [トレーニング仲間] ... | |
| | | |
| | ◆オフの交流 | |
| | 頻度: 週に1回程度 | |
| | 場所: カフェ、カラオケ | |
| | | |
| (下に続く) | (下に続く) | |
| | | |
+--------------------------------------------------------------------------------------------------+
設計をより具体的にするため、いくつか質問させていただけますでしょうか。
1. IDの形式について
「001」「800」という例をいただきましたが、このように常に3桁で、足りない分を0で埋める形式(ゼロパディング)でよろしいでしょうか?
2. 入力フォームの表示方法
「基礎情報」や「個別エピソード」を編集したり、新しく登録したりする際、入力フォームはどのように表示されるのが使いやすいですか?
3. リストの並び順について
4. キャラクターの区別
「育成対象ウマ娘」と「NPC/人間」をIDで分けますが、リスト上で見た目(例えば背景色を変えるなど)でも区別できるようにした方が便利でしょうか?
以上の点について、ご意見をお聞かせいただけますでしょうか。
もちろん、「とりあえず作ってみてから考えたい」という部分があれば、それでも全く問題ありません!
この設計案にご納得いただければ、まずは骨格となる部分から実際のツールの開発に着手していきたいと思います。
ありがとう、レイアウトイメージも非常にわかりやすく、的確に私のイメージを捉えている事がわかります。
基本的なスタイルはこれで良さそうです。
以下、確認事項について回答します。
IDの形式について
はい。「3桁固定、ゼロパディング」とします。
現時点で原作に登場するキャラクター数を考慮すると、3桁で十分な数が確保できるものと思われます。
入力フォームの表示方法
基礎情報
ではA案(カラム全体が入力フォームに切り替わる)、個別エピソード
ではB案(ポップアップウインドウ(モーダルウインドウ)が表示され、そこに入力する)としたいです。
リストの並び順について
キャラクターリスト
は確かに50音順でソートできると特定のウマ娘を見つけるときに役立ちそうです。(特に新たに交流があったキャラクター(タグを持たない)を探すときに便利そうです)
個別エピソード
は「時期」をキーとして最も古いものが先頭に、新しいものが下になるようになっていると良さそうです。
「時期」のデータ形式は表示上は「入学前」「デビュー前」「"ジュニア級/クラシック級/シニア級X年目" "n月" "前半/後半"」と大まかに3タイプに分け、データ形式としては 年(Year)
, 月(Month)
, 前半/後半(Turn)
の組み合わせとしてY-MM-T
の形式で処理するとソートしやすそうです。
入学前を「0年目」と解釈しY=0
、ジュニア級を「1年目」と解釈しY=1
、クラシックをY=2
、シニア1年目をY=3
とし、各月の前半をT=1
後半をT=2
とし、かつデビュー前をアプリ版のゲームシステム上の表現に合わせ「ジュニア級の6月前半までの不明な期間」としてMM=00, T=0
とすると一貫性のあるソートが可能になりそうです。
キャラクターの区別
そうですね、パッと見で区別しやすいと良さそうです。
一旦具体的な仕様はお任せします。、後でフィードバックに基づいて随時調整していきましょう。
承知いたしました。ご確認と、より詳細な仕様のご提示ありがとうございます!特に「時期」に関するデータ形式のアイデアは素晴らしいですね。ソートのしやすさを考えると非常に合理的で、実装のイメージが明確になりました。
では、いただいた回答を元に、設計をさらに具体化します。
001
, 801
)ご提案いただいた仕様で実装します。
Y-MM-T
(例: クラシック級4月後半 → 2-04-2
)Y
: 0(入学前), 1(ジュニア級), 2(クラシック級), 3(シニア級1年目), ...MM
: 00(デビュー前), 01-12(月)T
: 0(デビュー前), 1(前半), 2(後半)クラシック級 4月後半
)に変換して表示します。入力時も、プルダウンメニュー等で分かりやすく選択できるようにします。この設計案(ver 0.2)で、まずはツールのプロトタイプ(試作品)開発に着手したいと思います。
今後の進め方
1. 【私】プロトタイプの開発: まずは以下の機能を持つ、動くプロトタイプを作成します。
2. 【あなた】プロトタイプのレビュー: 出来上がったものをお渡ししますので、実際に触ってみて、ここまでの設計がイメージ通りか、使いにくい点はないかなどをフィードバックしてください。
3. 【共同】改善: いただいたフィードバックを元に、機能の修正や追加(入力フォームの実装など)を進めていきます。
この流れで進めてよろしいでしょうか?
もし問題なければ、早速プロトタイプの開発に取り掛かります!
ありがとう、ひとまず現時点で十分に基本設計は具体化できたと思います。
ここまでの内容をもとにプロトタイプの作成に進んでください。
承知いたしました。これまでの設計(ver 0.2)に基づき、ツールのプロトタイプを作成します。
まずは、基本的な骨格とデータ表示、保存・読み込み機能を優先して実装します。入力・編集機能は、この骨格にご納得いただけた後の次のステップで実装していきますね。
以下に、プロトタイプの全コードを記載します。
1. 以下のコード全体をコピーします。
2. PCのテキストエディタ(メモ帳など)を開き、コピーしたコードを貼り付けます。
3. ファイルの種類を「すべてのファイル」にして、ファイル名を sousaku-tool.html
のように、末尾を必ず .html
にして保存します。
4. 保存したHTMLファイルをダブルクリックして、ブラウザ(Google ChromeやFirefoxなど)で開くとツールが起動します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール プロトタイプ v0.3</title>
<style>
/* --- 基本レイアウト --- */
body {
font-family: sans-serif;
margin: 0;
background-color: #f0f2f5;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background-color: #fff;
padding: 10px 20px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.main-container {
display: flex;
flex-grow: 1;
overflow: hidden;
}
.column {
padding: 15px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow-y: auto;
}
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
button {
padding: 8px 12px;
border: none;
background-color: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag {
display: inline-block;
background-color: #e0e0e0;
padding: 3px 8px;
border-radius: 12px;
font-size: 0.8em;
margin: 2px;
}
/* --- キャラクターリスト --- */
#character-list-controls { margin-bottom: 15px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
#character-list li:hover { background-color: #f0f8ff; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
/* --- 個別エピソード --- */
.episode-card {
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
margin-bottom: 10px;
}
/* --- モーダル --- */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0; top: 0;
width: 100%; height: 100%;
background-color: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #fff;
padding: 20px;
border-radius: 8px;
width: 500px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.modal-content textarea { width: 95%; height: 150px; margin-top: 10px; padding: 10px; }
.modal-footer { text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<header>
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v0.3</span></h1>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データの読み込み</button>
<button id="save-button">データの保存</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls" class="button-group">
<button id="add-character-button">キャラクター一括登録</button>
<select id="sort-order">
<option value="id">ID順</option>
<option value="name">50音順</option>
</select>
</div>
<div id="character-list"><ul><!-- JSで描画 --></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view">
<h2>基礎情報</h2>
<div id="base-info-content"><!-- JSで描画 --></div>
</div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div id="episodes-view">
<h2>個別エピソード</h2>
<div id="episodes-content"><!-- JSで描画 --></div>
</div>
</div>
</div>
<!-- キャラクター一括登録モーダル -->
<div id="add-character-modal" class="modal">
<div class="modal-content">
<h3>キャラクター一括登録</h3>
<p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p>
<h4>育成ウマ娘 (ID: 001~)</h4>
<textarea id="player-char-input" placeholder="スペシャルウィーク,サイレンススズカ,トウカイテイオー"></textarea>
<h4>NPC/人間 (ID: 800~)</h4>
<textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea>
<div class="modal-footer button-group">
<button id="register-chars-button">登録</button>
<button id="close-modal-button" class="button-secondary">閉じる</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = {
characters: [],
baseInfo: {},
episodes: {}
};
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoContent = document.getElementById('base-info-content');
const episodesContent = document.getElementById('episodes-content');
const addCharModal = document.getElementById('add-character-modal');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "player" },
{ id: "002", name: "サイレンススズカ", type: "player" },
{ id: "800", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": {
firstContactDate: "1-07-1", // ジュニア級7月前半
firstContactPlace: "トレーニング後",
firstContactSummary: "自主練に付き合ってもらったのがきっかけ。",
sourceMemo: "メインスレッド >>123",
tags: ["友人", "トレーニング仲間", "オフの交流あり"],
offAcquaintance: { frequency: "週に1回程度", place: "カフェ、カラオケ" },
remarks: "にんじんハンバーグが好きらしい。"
}
},
episodes: {
"001": [
{ episodeId: "ep1", date: "2-04-2", place: "商店街", content: "福引で当てた温泉旅行券をプレゼントしてくれた。", sourceMemo: "短編スレッド >>45", remarks: "お返しに何をあげるか悩んでいる。" },
{ episodeId: "ep2", date: "1-12-1", place: "図書室", content: "おすすめの漫画を貸してくれた。", sourceMemo: "日常スレ >>88", remarks: "" }
]
}
};
renderAll();
}
// --- 描画関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId) {
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const sortedCharacters = [...db.characters].sort((a, b) => {
if (currentSortOrder === 'name') {
return a.name.localeCompare(b.name, 'ja');
}
return a.id.localeCompare(b.id); // デフォルトはID順
});
if (sortedCharacters.length === 0) {
charListUl.innerHTML = '<p style="color: #888; padding: 10px;">キャラクターが登録されていません。</p>';
return;
}
sortedCharacters.forEach(char => {
const li = document.createElement('li');
li.textContent = `${char.name} [#${char.id}]`;
li.dataset.id = char.id;
if (char.type === 'npc') {
li.classList.add('npc-char');
}
if (char.id === selectedCharacterId) {
li.classList.add('selected');
}
charListUl.appendChild(li);
});
}
// 時期データを表示用に変換
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
let yearStr = "";
if (y === 0) yearStr = "入学前";
else if (y === 1) yearStr = "ジュニア級";
else if (y === 2) yearStr = "クラシック級";
else if (y >= 3) yearStr = `シニア級${y-2}年目`;
if (m === 0 && t === 0) return "デビュー前";
const monthStr = `${m}月`;
const turnStr = t === 1 ? '前半' : '後半';
return `${yearStr} ${monthStr} ${turnStr}`;
}
function renderBaseInfo(charId) {
const info = db.baseInfo[charId];
if (!info) {
baseInfoContent.innerHTML = '<p style="color: #888;">このキャラクターの基礎情報はまだ登録されていません。</p><button>新規登録</button>';
return;
}
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
baseInfoContent.innerHTML = `
<h3>${db.characters.find(c => c.id === charId).name} [#${charId}]</h3>
<dl class="info-block">
<dt>現在の関係タグ</dt><dd>${tagsHtml}</dd>
</dl>
<dl class="info-block">
<dt>初交流</dt>
<dd>
時期: ${formatPeriod(info.firstContactDate)}<br>
場所: ${info.firstContactPlace || '未設定'}<br>
概要: ${info.firstContactSummary || '未設定'}<br>
出典: ${info.sourceMemo || '未設定'}
</dd>
</dl>
<dl class="info-block">
<dt>オフの交流</dt>
<dd>
頻度: ${info.offAcquaintance?.frequency || '未設定'}<br>
場所: ${info.offAcquaintance?.place || '未設定'}
</dd>
</dl>
<dl class="info-block">
<dt>備考</dt>
<dd>${info.remarks || '特になし'}</dd>
</dl>
<button class="button-secondary">編集</button>
`;
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
// 時期データでソート
episodes.sort((a, b) => a.date.localeCompare(b.date));
if (episodes.length === 0) {
episodesContent.innerHTML = '<p style="color: #888;">このキャラクターとの個別エピソードはまだありません。</p><button>新規登録</button>';
return;
}
episodesContent.innerHTML = episodes.map(ep => `
<div class="episode-card">
<strong>時期: ${formatPeriod(ep.date)}</strong><br>
<p><strong>場所:</strong> ${ep.place || '未設定'}</p>
<p><strong>内容:</strong> ${ep.content || '未設定'}</p>
<p><small><strong>出典:</strong> ${ep.sourceMemo || '未設定'}</small></p>
<p><small><strong>備考:</strong> ${ep.remarks || '特になし'}</small></p>
</div>
`).join('');
}
function clearViews() {
baseInfoContent.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
episodesContent.innerHTML = '';
}
// --- イベントハンドラ ---
// キャラクターリストのクリック
charListUl.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
selectedCharacterId = e.target.dataset.id;
// 選択状態の更新
document.querySelectorAll('#character-list li').forEach(li => li.classList.remove('selected'));
e.target.classList.add('selected');
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
}
});
// ソート順の変更
document.getElementById('sort-order').addEventListener('change', (e) => {
currentSortOrder = e.target.value;
renderCharacterList();
});
// モーダル表示
document.getElementById('add-character-button').addEventListener('click', () => {
addCharModal.style.display = 'flex';
});
document.getElementById('close-modal-button').addEventListener('click', () => {
addCharModal.style.display = 'none';
});
// キャラクター一括登録処理
document.getElementById('register-chars-button').addEventListener('click', () => {
const playerInput = document.getElementById('player-char-input').value.trim();
const npcInput = document.getElementById('npc-char-input').value.trim();
let lastPlayerId = Math.max(0, ...db.characters.filter(c => c.type === 'player').map(c => parseInt(c.id, 10)));
if (playerInput) {
const playerNames = playerInput.split(',').map(name => name.trim()).filter(Boolean);
playerNames.forEach(name => {
lastPlayerId++;
const newId = String(lastPlayerId).padStart(3, '0');
if (!db.characters.some(c => c.id === newId)) {
db.characters.push({ id: newId, name: name, type: 'player' });
}
});
}
let lastNpcId = Math.max(799, ...db.characters.filter(c => c.type === 'npc').map(c => parseInt(c.id, 10)));
if (npcInput) {
const npcNames = npcInput.split(',').map(name => name.trim()).filter(Boolean);
npcNames.forEach(name => {
lastNpcId++;
const newId = String(lastNpcId);
if (!db.characters.some(c => c.id === newId)) {
db.characters.push({ id: newId, name: name, type: 'npc' });
}
});
}
document.getElementById('player-char-input').value = '';
document.getElementById('npc-char-input').value = '';
addCharModal.style.display = 'none';
renderCharacterList();
});
// 保存処理
document.getElementById('save-button').addEventListener('click', () => {
const json = JSON.stringify(db, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 14); // YYYYMMDDHHMMSS
a.download = `sousaku_backup_${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
});
// 読み込み処理
const fileLoader = document.getElementById('file-loader');
document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
// 簡単なデータ検証
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
db = loadedDb;
selectedCharacterId = null; // 選択をリセット
renderAll();
alert('データを読み込みました。');
} else {
alert('エラー: ファイルの形式が正しくありません。');
}
} catch (error) {
alert('エラー: ファイルの読み込みに失敗しました。\n' + error);
}
};
reader.readAsText(file);
// 同じファイルを再度読み込めるようにvalueをリセット
event.target.value = '';
});
// --- 初期化 ---
createDummyData(); // 最初はダミーデータを表示
});
</script>
</body>
</html>
sousaku_backup_YYYYMMDDHHMMSS.json
という名前のJSONファイルとしてダウンロードされます。まずはこのプロトタイプを実際に触ってみて、以下の点について感触を教えていただけますでしょうか。
1. 全体的なレイアウトや操作感はイメージに近いですか?
2. キャラクターの一括登録の使い勝手はどうでしょうか?
3. リストの表示やソート、キャラクター選択の流れはスムーズに感じますか?
4. データの保存・読み込みは問題なく動作しますか?
もちろん、上記以外にも「ここの文字を大きくしたい」「このボタンの色はこっちがいい」といった細かなことから、「この機能はやっぱりこうしたい」といった大きなことまで、何でも気兼ねなくフィードバックをいただければと思います。
あなたのレビューを元に、次はいよいよ情報の入力・編集機能を実装していきましょう!
ありがとう、すべての機能が正常に機能しているようです。
-キャラクター追加後に保存すると正しくリストに追加されていることが確認できました
type
を "playable"
に変更したいです801
に変更したいですモブウマ娘
を追加したいです601
から開始type
は "mob"
プロトタイプのご確認と詳細なフィードバック、ありがとうございます!
すべての機能がイメージ通りに動作しているとのことで、安心いたしました。
いただいた調整点、承知いたしました。創作の世界観に合わせてツールを最適化していく、とても良い調整だと思います。
では、ご指摘の3点を反映し、いよいよ中核機能である情報の入力・編集機能を実装した新しいバージョンを作成します。
type
をplayer
からplayable
に変更しました。801
からに変更しました。601
から、type
はmob
)以前と同様に、以下のコードをコピーしてsousaku-tool.html
として保存し、ブラウザで開いてください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール プロトタイプ v0.4</title>
<style>
/* --- 基本レイアウト --- */
body { font-family: sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; flex-direction: column; height: 100vh; }
header { background-color: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.main-container { display: flex; flex-grow: 1; overflow: hidden; }
.column { padding: 15px; background-color: #fff; margin: 10px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-y: auto; }
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
.column-header { display: flex; justify-content: space-between; align-items: center; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
button { padding: 8px 12px; border: none; background-color: #007bff; color: white; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: background-color 0.2s; }
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-danger { background-color: #dc3545; }
.button-danger:hover { background-color: #c82333; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag { display: inline-block; background-color: #e0e0e0; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px; }
/* --- フォーム要素 --- */
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input[type="text"], .form-group textarea, .form-group select { width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.form-group textarea { height: 80px; resize: vertical; }
/* --- キャラクターリスト --- */
#character-list-controls { margin-bottom: 15px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; }
#character-list li:hover { background-color: #f0f8ff; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
#character-list li.mob-char { background-color: #fffbe6; } /* モブウマ娘用の色 */
#character-list li.mob-char:hover { background-color: #fff3cd; }
#character-list li.mob-char.selected { background-color: #f8c963; color: #333; }
/* --- 個別エピソード --- */
.episode-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 10px; position: relative; }
.episode-card-actions { position: absolute; top: 10px; right: 10px; }
/* --- モーダル --- */
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center; }
.modal-content { background-color: #fff; padding: 20px; border-radius: 8px; width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.modal-content textarea { width: 95%; height: 100px; margin-top: 5px; padding: 10px; }
.modal-footer { text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<header>
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v0.4</span></h1>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データの読み込み</button>
<button id="save-button">データの保存</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls" class="button-group">
<button id="add-character-button">キャラクター一括登録</button>
<select id="sort-order"><option value="id">ID順</option><option value="name">50音順</option></select>
</div>
<div id="character-list"><ul></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view"><!-- 表示用 --></div>
<div id="base-info-form" style="display: none;"><!-- 編集用 --></div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div class="column-header">
<h2>個別エピソード</h2>
<button id="add-episode-button" style="display: none;">新規登録</button>
</div>
<div id="episodes-content"></div>
</div>
</div>
<!-- キャラクター一括登録モーダル -->
<div id="add-character-modal" class="modal">
<div class="modal-content">
<h3>キャラクター一括登録</h3>
<p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p>
<h4>育成ウマ娘 (ID: 001~)</h4>
<textarea id="playable-char-input" placeholder="スペシャルウィーク,サイレンススズカ"></textarea>
<h4>モブウマ娘 (ID: 601~)</h4>
<textarea id="mob-char-input" placeholder="リボン付きのウマ娘,元気なウマ娘"></textarea>
<h4>NPC/人間 (ID: 801~)</h4>
<textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea>
<div class="modal-footer button-group">
<button id="register-chars-button">登録</button>
<button id="close-modal-button" class="button-secondary">閉じる</button>
</div>
</div>
</div>
<!-- 個別エピソード編集モーダル -->
<div id="episode-modal" class="modal">
<div class="modal-content">
<h3 id="episode-modal-title">個別エピソードの編集</h3>
<div class="form-group">
<label for="ep-date">時期</label>
<div id="ep-date-selector" class="button-group"></div>
</div>
<div class="form-group"><label for="ep-place">場所</label><input type="text" id="ep-place"></div>
<div class="form-group"><label for="ep-content">内容</label><textarea id="ep-content"></textarea></div>
<div class="form-group"><label for="ep-sourceMemo">出典</label><input type="text" id="ep-sourceMemo"></div>
<div class="form-group"><label for="ep-remarks">備考</label><input type="text" id="ep-remarks"></div>
<input type="hidden" id="ep-id">
<div class="modal-footer button-group">
<button id="save-episode-button">保存</button>
<button id="close-episode-modal-button" class="button-secondary">キャンセル</button>
</div>
</div>
</div>
<script>
// (JavaScriptコードは長いため、次のセルに記載します)
</script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {} };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const addCharModal = document.getElementById('add-character-modal');
const episodeModal = document.getElementById('episode-modal');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" },
{ id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": {
firstContactDate: "1-07-1", tags: ["友人", "トレーニング仲間", "オフの交流あり"],
firstContactPlace: "トレーニング後", firstContactSummary: "自主練に付き合ってもらった。",
sourceMemo: "メインスレッド >>123", offAcquaintance: { frequency: "週1", place: "カフェ" },
remarks: "にんじんハンバーグが好き。"
}
},
episodes: {
"001": [
{ id: "ep" + Date.now(), date: "1-12-1", place: "図書室", content: "おすすめの漫画を貸してくれた。", sourceMemo: "日常スレ >>88", remarks: "" },
{ id: "ep" + (Date.now()+1), date: "2-04-2", place: "商店街", content: "福引で当てた温泉旅行券をプレゼントしてくれた。", sourceMemo: "短編スレ >>45", remarks: "お返しに悩む。" }
]
}
};
renderAll();
}
// --- 描画系関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const sorted = [...db.characters].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) {
charListUl.innerHTML = '<p style="color: #888; padding: 10px;">キャラクター未登録</p>'; return;
}
sorted.forEach(c => {
const li = document.createElement('li');
li.textContent = `${c.name} [#${c.id}]`; li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
charListUl.appendChild(li);
});
}
function renderBaseInfo(charId) {
showBaseInfoView(charId);
baseInfoForm.style.display = 'none';
}
function showBaseInfoView(charId){
const info = db.baseInfo[charId];
const charName = db.characters.find(c => c.id === charId).name;
baseInfoView.style.display = 'block';
if (!info) {
baseInfoView.innerHTML = `<h2>基礎情報: ${charName}</h2><p style="color: #888;">未登録</p><button id="edit-base-info-btn">新規登録</button>`;
} else {
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
baseInfoView.innerHTML = `
<h2>基礎情報: ${charName}</h2>
<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>
<dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl>
<dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl>
<dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl>
<button id="edit-base-info-btn" class="button-secondary">編集</button>`;
}
document.getElementById('edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId));
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
episodes.sort((a, b) => a.date.localeCompare(b.date));
if (episodes.length === 0) {
episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return;
}
episodesContent.innerHTML = episodes.map(ep => `
<div class="episode-card">
<div class="episode-card-actions button-group">
<button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button>
<button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button>
</div>
<strong>時期: ${formatPeriod(ep.date)}</strong>
<p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p>
<p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p>
</div>`).join('');
}
function clearViews() {
baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
baseInfoForm.innerHTML = '';
episodesContent.innerHTML = '';
addEpisodeButton.style.display = 'none';
}
// --- フォーム系関数 ---
function showBaseInfoForm(charId) {
const info = db.baseInfo[charId] || {};
const off = info.offAcquaintance || {};
baseInfoView.style.display = 'none';
baseInfoForm.style.display = 'block';
baseInfoForm.innerHTML = `
<h2>基礎情報の編集</h2>
<div class="form-group"><label>関係タグ(カンマ区切り)</label><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}"></div>
<div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div>
<div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div>
<div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div>
<div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div>
<div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div>
<div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div>
<div class="button-group">
<button id="save-base-info-btn">保存</button>
<button id="cancel-base-info-btn" class="button-secondary">キャンセル</button>
</div>`;
createPeriodSelector('first-contact-date-selector', info.firstContactDate);
document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId));
document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId));
}
function saveBaseInfo(charId) {
const newInfo = {
tags: document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean),
firstContactDate: getPeriodFromSelector('first-contact-date-selector'),
firstContactPlace: document.getElementById('fc-place-input').value,
firstContactSummary: document.getElementById('fc-summary-input').value,
sourceMemo: document.getElementById('fc-source-input').value,
offAcquaintance: {
frequency: document.getElementById('off-freq-input').value,
place: document.getElementById('off-place-input').value,
},
remarks: document.getElementById('remarks-input').value,
};
db.baseInfo[charId] = newInfo;
renderBaseInfo(charId);
}
function showEpisodeModal(charId, episodeId = null) {
const isNew = episodeId === null;
const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId);
document.getElementById('episode-modal-title').textContent = isNew ? '個別エピソードの新規登録' : '個別エピソードの編集';
createPeriodSelector('ep-date-selector', episode.date);
document.getElementById('ep-place').value = episode.place || '';
document.getElementById('ep-content').value = episode.content || '';
document.getElementById('ep-sourceMemo').value = episode.sourceMemo || '';
document.getElementById('ep-remarks').value = episode.remarks || '';
document.getElementById('ep-id').value = isNew ? '' : episodeId;
episodeModal.style.display = 'flex';
}
function saveEpisode() {
const charId = selectedCharacterId;
const episodeId = document.getElementById('ep-id').value;
const isNew = episodeId === '';
const episodeData = {
id: isNew ? 'ep' + Date.now() : episodeId,
date: getPeriodFromSelector('ep-date-selector'),
place: document.getElementById('ep-place').value,
content: document.getElementById('ep-content').value,
sourceMemo: document.getElementById('ep-sourceMemo').value,
remarks: document.getElementById('ep-remarks').value,
};
if (!db.episodes[charId]) db.episodes[charId] = [];
if (isNew) {
db.episodes[charId].push(episodeData);
} else {
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
db.episodes[charId][index] = episodeData;
}
episodeModal.style.display = 'none';
renderEpisodes(charId);
}
function deleteEpisode(charId, episodeId) {
if (!confirm('このエピソードを削除しますか?')) return;
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
db.episodes[charId].splice(index, 1);
renderEpisodes(charId);
}
// --- 時期データ関連 ---
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
if (m === 0 && t === 0) return "デビュー前";
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue = '1-04-1') {
const container = document.getElementById(containerId);
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [1, 4, 1];
const yearSelect = document.createElement('select');
for (let i = 0; i <= 5; i++) { // 入学前からシニア4年目まで
const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`;
yearSelect.options.add(new Option(name, i));
}
yearSelect.value = y;
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0));
for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
monthSelect.value = m;
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1));
turnSelect.options.add(new Option('後半', 2));
turnSelect.value = t;
container.innerHTML = '';
container.append(yearSelect, monthSelect, turnSelect);
}
function getPeriodFromSelector(containerId) {
const selects = document.getElementById(containerId).querySelectorAll('select');
return `${selects[0].value}-${String(selects[1].value).padStart(2, '0')}-${selects[2].value}`;
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
selectedCharacterId = e.target.dataset.id; renderAll();
}
});
document.getElementById('sort-order').addEventListener('change', (e) => {
currentSortOrder = e.target.value; renderCharacterList();
});
document.getElementById('add-character-button').addEventListener('click', () => addCharModal.style.display = 'flex');
document.getElementById('close-modal-button').addEventListener('click', () => addCharModal.style.display = 'none');
document.getElementById('register-chars-button').addEventListener('click', () => {
const process = (input, type, startId) => {
const names = document.getElementById(input).value.trim().split(',').map(n => n.trim()).filter(Boolean);
let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id)));
names.forEach(name => {
lastId++;
const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId);
if (!db.characters.some(c => c.id === newId)) {
db.characters.push({ id: newId, name, type });
}
});
document.getElementById(input).value = '';
};
process('playable-char-input', 'playable', 1);
process('mob-char-input', 'mob', 601);
process('npc-char-input', 'npc', 801);
addCharModal.style.display = 'none';
renderCharacterList();
});
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('close-episode-modal-button').addEventListener('click', () => episodeModal.style.display = 'none');
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('edit-episode-btn')) {
showEpisodeModal(selectedCharacterId, target.dataset.episodeId);
}
if (target.classList.contains('delete-episode-btn')) {
deleteEpisode(selectedCharacterId, target.dataset.episodeId);
}
});
// 保存・読み込み
document.getElementById('save-button').addEventListener('click', () => {
const json = JSON.stringify(db, null, 2);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'}));
a.download = `sousaku_backup_${new Date().toISOString().slice(0,19).replace(/[T:-]/g, '')}.json`;
a.click(); URL.revokeObjectURL(a.href);
});
const fileLoader = document.getElementById('file-loader');
document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
db = loadedDb; selectedCharacterId = null; renderAll(); alert('データを読み込みました。');
} else { alert('エラー: ファイル形式が不正です。'); }
} catch (error) { alert('エラー: ファイルの読み込みに失敗しました。\n' + error); }
};
reader.readAsText(file); event.target.value = '';
});
// --- 初期化 ---
createDummyData();
});
1. 基礎情報の入力・編集:
2. 個別エピソードの入力・編集:
3. 時期の入力:
今回でかなりツールとして実用的な段階になったかと思います。
実際にあなたの創作データを入力するようなイメージで触ってみて、使い勝手や改善点など、またお気軽にフィードバックをいただければと思います!
ありがとう、今回も正しくすべての機能が実装できているようです。
確認済のポイントと調整したい点を挙げるので、確認してください。
その後、「最初の要件定義に含まれ、かつ未実装の機能」や「調整したい点」について検討し、バージョンアップ案を提案したり、より詳しい仕様の調整に必要と思われる質問を行ったりしてください。
現時点では確認と質問に集中し、まだ実際のコード作成に進まないでください。
入学前
を選択したときは月とターンの選択、デビュー前
を選択したときはターンの選択をロックし操作不要(無効化)できますか?ご確認いただき、ありがとうございます!
すべての主要機能が意図した通りに動作しているとのこと、そして操作感についてもご満足いただけたようで、とても安心しました。
いただいた調整点と、今後の開発方針について、以下に私の考えをまとめました。
ご指示の通り、今回はコードの作成は行わず、次のステップに向けた確認と質問に重点を置いています。
> 時期の入力で入学前
を選択したときは月とターンの選択、デビュー前
を選択したときはターンの選択をロックし操作不要(無効化)できますか?
はい、実装可能です。
これは操作ミスを防ぎ、入力をよりスムーズにするための素晴らしい改善案だと思います。
具体的には、以下のような動作を想定しています。
Y=0, MM=00, T=0
のような形)で処理されます。この仕様で、より直感的で使いやすいツールになるかと思います。
最初の要件定義に立ち返ると、まだ実装されていない主要な機能が一つあります。それは「タグによるキャラクターの検索・絞り込み機能」です。
そこで、先ほどの「時期入力の改善」と、この「タグ検索機能」、そしてあると便利な管理機能を合わせて、次のバージョンアップ案として以下を提案させてください。
1. 時期入力UIの改善(今回ご提案いただいた内容)
2. タグ検索機能の実装
3. キャラクター情報の編集・削除機能
上記の機能案、特に「タグ検索」と「キャラクター編集」について、仕様をより具体的にするため、いくつかご質問させていただけますでしょうか。
【質問1:タグ検索機能について】
【質問2:キャラクターの編集・削除機能について】
【質問3:その他の機能について】
以上となります。
お忙しいところ恐縮ですが、これらの質問についてお考えをお聞かせいただけますでしょうか。
いただいたご回答をもとに、次期バージョン(v0.5)の最終的な仕様を固め、開発に進めたいと思います!
ありがとう、順を追って回答していきます。
【質問1:タグ検索機能について】
協力者候補
と(脚質)
や(距離)
のタグを組み合わせて拾い出す等協力者候補
+ 中距離
->中距離のトレーニングで協力してくれそうなウマ娘の候補として予定していた候補をリストに出す初交流
やオフの交流
の項目未入力にしておきたい【質問2:キャラクターの編集・削除機能について】
A案: キャラクターリストで、各キャラクター名の隣に小さな「編集」「削除」アイコンを配置する。
の方がよさそう【質問3:その他の機能について】
ご回答ありがとうございます。非常に明確なご指示をいただき、ツールの目指す方向性がさらに具体的になりました。特にタグ検索のユースケースは、実際の創作活動に即したもので大変参考になります。
いただいたご意見をすべて反映し、次期バージョン(v0.5)の最終的な設計案を以下にまとめました。
この内容でよろしければ、開発に着手したいと思います。
協力者候補, 中距離
のようにカンマ区切りで複数のタグを入力すると、入力されたすべてのタグを持つキャラクターのみがリストに絞り込み表示されます。以上の設計案(ver 0.5)で、あなたの理想とするツールにさらに近づけるかと存じます。
この内容で開発を進めてもよろしいでしょうか?ご確認をお願いいたします。
ありがとう、現時点ではこれで問題ないと思います。
コーディングに入ってください。
承知いたしました。設計案(ver 0.5)に基づき、コーディングに入ります。
タグ検索機能、キャラクターの編集・削除機能、そして時期入力のUI改善を実装しました。
コードが長くなったため、HTMLとJavaScriptを分けて記載します。
ver 0.4と同様に、以下のHTMLコードをsousaku-tool.html
などのファイル名で保存し、そのHTMLファイル内に後述のJavaScriptコードを貼り付けてから、ブラウザで開いてください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール プロトタイプ v0.5</title>
<style>
/* --- 基本レイアウト --- */
body { font-family: sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; flex-direction: column; height: 100vh; }
header { background-color: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.main-container { display: flex; flex-grow: 1; overflow: hidden; }
.column { padding: 15px; background-color: #fff; margin: 10px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-y: auto; }
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
.column-header { display: flex; justify-content: space-between; align-items: center; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
button { padding: 8px 12px; border: none; background-color: #007bff; color: white; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: background-color 0.2s; }
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-danger { background-color: #dc3545; }
.button-danger:hover { background-color: #c82333; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag { display: inline-block; background-color: #e0e0e0; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px; }
/* --- フォーム要素 --- */
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input[type="text"], .form-group textarea, .form-group select { width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.form-group select:disabled { background-color: #e9ecef; }
.form-group textarea { height: 80px; resize: vertical; }
/* --- キャラクターリスト --- */
#character-list-controls { margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; display: flex; justify-content: space-between; align-items: center; }
#character-list li:hover { background-color: #f0f8ff; }
#character-list li .char-name { flex-grow: 1; }
#character-list li .char-actions { display: none; }
#character-list li:hover .char-actions { display: block; }
#character-list li .char-actions button { padding: 2px 5px; font-size: 0.8em; margin-left: 5px; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.selected .char-actions { display: block; }
#character-list li.playable-char { /* 変更なし */ }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
#character-list li.mob-char { background-color: #fffbe6; }
#character-list li.mob-char:hover { background-color: #fff3cd; }
#character-list li.mob-char.selected { background-color: #f8c963; color: #333; }
/* --- 個別エピソード --- */
.episode-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 10px; position: relative; }
.episode-card-actions { position: absolute; top: 10px; right: 10px; }
/* --- モーダル --- */
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center; }
.modal-content { background-color: #fff; padding: 20px; border-radius: 8px; width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.modal-content textarea { width: 95%; height: 100px; margin-top: 5px; padding: 10px; }
.modal-footer { text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<header>
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v0.5</span></h1>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データの読み込み</button>
<button id="save-button">データの保存</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls">
<div class="button-group">
<button id="add-character-button">一括登録</button>
<select id="sort-order"><option value="id">ID順</option><option value="name">50音順</option></select>
</div>
<input type="text" id="tag-search-input" placeholder="タグで検索 (カンマ区切り)..." style="width: 95%;">
</div>
<div id="character-list"><ul></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view"></div>
<div id="base-info-form" style="display: none;"></div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div class="column-header">
<h2>個別エピソード</h2>
<button id="add-episode-button" style="display: none;">新規登録</button>
</div>
<div id="episodes-content"></div>
</div>
</div>
<!-- キャラクター一括登録モーダル -->
<div id="add-character-modal" class="modal">
<div class="modal-content">
<h3>キャラクター一括登録</h3>
<p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p>
<h4>育成ウマ娘 (ID: 001~)</h4>
<textarea id="playable-char-input" placeholder="スペシャルウィーク,サイレンススズカ"></textarea>
<h4>モブウマ娘 (ID: 601~)</h4>
<textarea id="mob-char-input" placeholder="リボン付きのウマ娘,元気なウマ娘"></textarea>
<h4>NPC/人間 (ID: 801~)</h4>
<textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea>
<div class="modal-footer button-group">
<button id="register-chars-button">登録</button>
<button id="close-modal-button" class="button-secondary">閉じる</button>
</div>
</div>
</div>
<!-- 個別エピソード編集モーダル -->
<div id="episode-modal" class="modal">
<div class="modal-content">
<h3 id="episode-modal-title">個別エピソードの編集</h3>
<div class="form-group"><label>時期</label><div id="ep-date-selector" class="button-group"></div></div>
<div class="form-group"><label>場所</label><input type="text" id="ep-place"></div>
<div class="form-group"><label>内容</label><textarea id="ep-content"></textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="ep-sourceMemo"></div>
<div class="form-group"><label>備考</label><input type="text" id="ep-remarks"></div>
<input type="hidden" id="ep-id">
<div class="modal-footer button-group">
<button id="save-episode-button">保存</button>
<button id="close-episode-modal-button" class="button-secondary">キャンセル</button>
</div>
</div>
</div>
<script>
// ここに後述のJavaScriptコードを貼り付けてください
</script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {} };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const addCharModal = document.getElementById('add-character-modal');
const episodeModal = document.getElementById('episode-modal');
const tagSearchInput = document.getElementById('tag-search-input');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" },
{ id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "003", name: "トウカイテイオー", type: "playable" },
{ id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": { tags: ["友人", "トレーニング仲間", "中距離"], firstContactDate: "1-07-1", firstContactPlace: "トレーニング後", firstContactSummary: "自主練に付き合ってもらった。", sourceMemo: "メインスレッド >>123", offAcquaintance: { frequency: "週1", place: "カフェ" }, remarks: "にんじんハンバーグが好き。" },
"002": { tags: ["友人", "逃げ"] },
"003": { tags: ["協力者候補", "中距離"] }
},
episodes: {
"001": [
{ id: "ep1", date: "1-12-1", place: "図書室", content: "おすすめの漫画を貸してくれた。", sourceMemo: "日常スレ >>88", remarks: "" },
{ id: "ep2", date: "2-04-2", place: "商店街", content: "福引で当てた温泉旅行券をプレゼントしてくれた。", sourceMemo: "短編スレ >>45", remarks: "お返しに悩む。" }
]
}
};
renderAll();
}
// --- 描画系関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId && db.characters.some(c => c.id === selectedCharacterId)) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
selectedCharacterId = null;
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const searchTags = tagSearchInput.value.trim().split(',').map(t => t.trim()).filter(Boolean);
let filtered = db.characters;
if (searchTags.length > 0) {
filtered = db.characters.filter(char => {
const info = db.baseInfo[char.id];
if (!info || !info.tags || info.tags.length === 0) return false;
return searchTags.every(searchTag => info.tags.includes(searchTag));
});
}
const sorted = [...filtered].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) {
charListUl.innerHTML = '<p style="color: #888; padding: 10px;">該当キャラクターなし</p>'; return;
}
sorted.forEach(c => {
const li = document.createElement('li');
li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
li.innerHTML = `
<span class="char-name">${c.name} [#${c.id}]</span>
<span class="char-actions">
<button class="edit-char-btn button-secondary" data-id="${c.id}">編集</button>
<button class="delete-char-btn button-danger" data-id="${c.id}">削除</button>
</span>`;
charListUl.appendChild(li);
});
}
// (他のrender関数やイベントハンドラはver0.4からほぼ変更なしのため、主要な変更箇所のみ記載)
// ... (ver 0.4の renderBaseInfo, showBaseInfoView, renderEpisodes, clearViews, showBaseInfoForm, saveBaseInfo, showEpisodeModal, saveEpisode, deleteEpisode をここに挿入)
function renderBaseInfo(charId){ showBaseInfoView(charId); baseInfoForm.style.display = 'none'; }
function showBaseInfoView(charId){ const info = db.baseInfo[charId]; const charName = db.characters.find(c => c.id === charId).name; baseInfoView.style.display = 'block'; if (!info) { baseInfoView.innerHTML = `<h2>基礎情報: ${charName}</h2><p style="color: #888;">未登録</p><button id="edit-base-info-btn">新規登録</button>`; } else { const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし'; baseInfoView.innerHTML = `<h2>基礎情報: ${charName}</h2><dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl><dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl><dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl><dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl><button id="edit-base-info-btn" class="button-secondary">編集</button>`; } document.getElementById('edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId)); }
function renderEpisodes(charId){ const episodes = db.episodes[charId] || []; episodes.sort((a, b) => a.date.localeCompare(b.date)); if (episodes.length === 0) { episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return; } episodesContent.innerHTML = episodes.map(ep => `<div class="episode-card"><div class="episode-card-actions button-group"><button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button><button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button></div><strong>時期: ${formatPeriod(ep.date)}</strong><p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p><p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p></div>`).join(''); }
function clearViews(){ baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>'; baseInfoForm.innerHTML = ''; episodesContent.innerHTML = ''; addEpisodeButton.style.display = 'none'; }
function showBaseInfoForm(charId){ const info = db.baseInfo[charId] || {}; const off = info.offAcquaintance || {}; baseInfoView.style.display = 'none'; baseInfoForm.style.display = 'block'; baseInfoForm.innerHTML = `<h2>基礎情報の編集</h2><div class="form-group"><label>関係タグ(カンマ区切り)</label><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}"></div><div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div><div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div><div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div><div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div><div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div><div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div><div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div><div class="button-group"><button id="save-base-info-btn">保存</button><button id="cancel-base-info-btn" class="button-secondary">キャンセル</button></div>`; createPeriodSelector('first-contact-date-selector', info.firstContactDate); document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId)); document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId)); }
function saveBaseInfo(charId){ const newInfo = { tags: document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean), firstContactDate: getPeriodFromSelector('first-contact-date-selector'), firstContactPlace: document.getElementById('fc-place-input').value, firstContactSummary: document.getElementById('fc-summary-input').value, sourceMemo: document.getElementById('fc-source-input').value, offAcquaintance: { frequency: document.getElementById('off-freq-input').value, place: document.getElementById('off-place-input').value, }, remarks: document.getElementById('remarks-input').value, }; db.baseInfo[charId] = newInfo; renderBaseInfo(charId); }
function showEpisodeModal(charId, episodeId = null){ const isNew = episodeId === null; const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId); document.getElementById('episode-modal-title').textContent = isNew ? '個別エピソードの新規登録' : '個別エピソードの編集'; createPeriodSelector('ep-date-selector', episode.date); document.getElementById('ep-place').value = episode.place || ''; document.getElementById('ep-content').value = episode.content || ''; document.getElementById('ep-sourceMemo').value = episode.sourceMemo || ''; document.getElementById('ep-remarks').value = episode.remarks || ''; document.getElementById('ep-id').value = isNew ? '' : episodeId; episodeModal.style.display = 'flex'; }
function saveEpisode(){ const charId = selectedCharacterId; const episodeId = document.getElementById('ep-id').value; const isNew = episodeId === ''; const episodeData = { id: isNew ? 'ep' + Date.now() : episodeId, date: getPeriodFromSelector('ep-date-selector'), place: document.getElementById('ep-place').value, content: document.getElementById('ep-content').value, sourceMemo: document.getElementById('ep-sourceMemo').value, remarks: document.getElementById('ep-remarks').value, }; if (!db.episodes[charId]) db.episodes[charId] = []; if (isNew) { db.episodes[charId].push(episodeData); } else { const index = db.episodes[charId].findIndex(ep => ep.id === episodeId); db.episodes[charId][index] = episodeData; } episodeModal.style.display = 'none'; renderEpisodes(charId); }
function deleteEpisode(charId, episodeId){ if (!confirm('このエピソードを削除しますか?')) return; const index = db.episodes[charId].findIndex(ep => ep.id === episodeId); db.episodes[charId].splice(index, 1); renderEpisodes(charId); }
// --- 時期データ関連 (改善版) ---
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
if (y === 0) return "入学前";
if (m === 0) return "デビュー前";
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue = '1-04-1') {
const container = document.getElementById(containerId);
container.innerHTML = '';
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [1, 4, 1];
const yearSelect = document.createElement('select');
for (let i = 0; i <= 5; i++) { const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`; yearSelect.options.add(new Option(name, i)); }
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0)); for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1)); turnSelect.options.add(new Option('後半', 2));
container.append(yearSelect, monthSelect, turnSelect);
yearSelect.value = y; monthSelect.value = m; turnSelect.value = t;
const updateSelectors = () => {
const currentYear = yearSelect.value;
const currentMonth = monthSelect.value;
monthSelect.disabled = (currentYear == 0);
turnSelect.disabled = (currentYear == 0 || currentMonth == 0);
};
yearSelect.addEventListener('change', updateSelectors);
monthSelect.addEventListener('change', updateSelectors);
updateSelectors(); // 初期状態を設定
}
function getPeriodFromSelector(containerId) {
const selects = document.getElementById(containerId).querySelectorAll('select');
if (selects[0].value == 0) return '0-00-0';
if (selects[1].value == 0) return `${selects[0].value}-00-0`;
return `${selects[0].value}-${String(selects[1].value).padStart(2, '0')}-${selects[2].value}`;
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
const target = e.target;
const li = target.closest('li');
if (!li) return;
const charId = li.dataset.id;
if (target.classList.contains('edit-char-btn')) { // 編集ボタン
e.stopPropagation(); // liの選択イベントを発火させない
const char = db.characters.find(c => c.id === charId);
const newName = prompt(`「${char.name}」の新しい名前を入力してください:`, char.name);
if (newName && newName.trim() !== '') {
char.name = newName.trim();
renderAll();
}
} else if (target.classList.contains('delete-char-btn')) { // 削除ボタン
e.stopPropagation();
const char = db.characters.find(c => c.id === charId);
const hasData = db.baseInfo[charId] || (db.episodes[charId] && db.episodes[charId].length > 0);
const message = hasData
? `「${char.name}」を削除すると、関連する基礎情報とすべての個別エピソードも完全に削除されます。\n本当によろしいですか?`
: `「${char.name}」を削除しますか?`;
if (confirm(message)) {
db.characters = db.characters.filter(c => c.id !== charId);
delete db.baseInfo[charId];
delete db.episodes[charId];
renderAll();
}
} else { // リストアイテム本体
selectedCharacterId = charId;
renderAll();
}
});
tagSearchInput.addEventListener('input', renderCharacterList);
document.getElementById('sort-order').addEventListener('change', (e) => { currentSortOrder = e.target.value; renderCharacterList(); });
document.getElementById('add-character-button').addEventListener('click', () => addCharModal.style.display = 'flex');
document.getElementById('close-modal-button').addEventListener('click', () => addCharModal.style.display = 'none');
document.getElementById('register-chars-button').addEventListener('click', () => { const process = (input, type, startId) => { const names = document.getElementById(input).value.trim().split(',').map(n => n.trim()).filter(Boolean); let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id))); names.forEach(name => { lastId++; const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId); if (!db.characters.some(c => c.id === newId)) { db.characters.push({ id: newId, name, type }); } }); document.getElementById(input).value = ''; }; process('playable-char-input', 'playable', 1); process('mob-char-input', 'mob', 601); process('npc-char-input', 'npc', 801); addCharModal.style.display = 'none'; renderCharacterList(); });
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('close-episode-modal-button').addEventListener('click', () => episodeModal.style.display = 'none');
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('edit-episode-btn')) { showEpisodeModal(selectedCharacterId, target.dataset.episodeId); } if (target.classList.contains('delete-episode-btn')) { deleteEpisode(selectedCharacterId, target.dataset.episodeId); } });
document.getElementById('save-button').addEventListener('click', () => { const json = JSON.stringify(db, null, 2); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'})); a.download = `sousaku_backup_${new Date().toISOString().slice(0,19).replace(/[T:-]/g, '')}.json`; a.click(); URL.revokeObjectURL(a.href); });
const fileLoader = document.getElementById('file-loader'); document.getElementById('load-button').addEventListener('click', () => fileLoader.click()); fileLoader.addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const loadedDb = JSON.parse(e.target.result); if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) { db = loadedDb; selectedCharacterId = null; renderAll(); alert('データを読み込みました。'); } else { alert('エラー: ファイル形式が不正です。'); } } catch (error) { alert('エラー: ファイルの読み込みに失敗しました。\n' + error); } }; reader.readAsText(file); event.target.value = ''; });
// --- 初期化 ---
createDummyData();
});
1. タグ検索機能:
2. キャラクターの編集・削除:
3. 時期入力のUI改善:
4. タグのみの先行登録:
これらの機能が意図通りに動作するか、また使い勝手はどうか、ご確認をお願いいたします。
今回で当初の要件はほぼ網羅できたかと思いますが、使ってみて気になった点があれば、どんな些細なことでもお気軽にお申し付けください!
コードブロックが長くなりすぎるのを避けるためにhtmlコードとJavaScriptコードを分割するのは構いませんが、(ver 0.4の renderBaseInfo, showBaseInfoView, renderEpisodes, clearViews, showBaseInfoForm, saveBaseInfo, showEpisodeModal, saveEpisode, deleteEpisode をここに挿入
のような指示はコーディング知識がほとんどない私にとっては非常に困難なタスクになります。
コードを分割出力する場合は、最初に分割コードであることを明示し、「コードブロックの全体コピー」機能だけを使用して、スクリプト全体を指定行に丸ごとペースト、または分割されたスクリプトの1つ目をテキストエディタにペーストし、その末端行で改行して2つ目をペーストして結合するのような形式でファイル化できるようにしてください。
今回はhtmlコードの保存については特に問題ありません。
また、スクリプト部を挿入する場所の指定も明確です。
しかし、JavaScriptコード全体を適切に構築するのは困難です。
以下のいずれかの方法で再度出力してください。
大変失礼いたしました。
コーディング知識がほとんどない、という大前提を忘れた、非常に不親切なご提示をしてしまいました。コードの省略は、かえって混乱と手間を増やすだけでしたね。深くお詫び申し上げます。
ご指摘いただいた通り、今後はコードを省略せず、コピー&ペーストだけで完結する形でご提示することを徹底します。
今回は、ver 0.5のJavaScriptコード全体を、省略せずに単一のコードブロックで再度出力します。
以下の手順で、お手数ですが再度ファイルの作成をお願いいたします。
1. 前回の応答で提示したHTMLコードをコピーし、sousaku-tool.html
として保存します。(ここまでは前回と同じです)
2. 保存したsousaku-tool.html
をテキストエディタで開き、以下のJavaScriptコードをコピーします。
3. HTMLコード内の<script>
タグと</script>
タグの間に、コピーしたJavaScriptコードを丸ごと貼り付けてください。
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {} };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const addCharModal = document.getElementById('add-character-modal');
const episodeModal = document.getElementById('episode-modal');
const tagSearchInput = document.getElementById('tag-search-input');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" },
{ id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "003", name: "トウカイテイオー", type: "playable" },
{ id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": { tags: ["友人", "トレーニング仲間", "中距離"], firstContactDate: "1-07-1", firstContactPlace: "トレーニング後", firstContactSummary: "自主練に付き合ってもらった。", sourceMemo: "メインスレッド >>123", offAcquaintance: { frequency: "週1", place: "カフェ" }, remarks: "にんじんハンバーグが好き。" },
"002": { tags: ["友人", "逃げ"] },
"003": { tags: ["協力者候補", "中距離"] }
},
episodes: {
"001": [
{ id: "ep" + Date.now(), date: "1-12-1", place: "図書室", content: "おすすめの漫画を貸してくれた。", sourceMemo: "日常スレ >>88", remarks: "" },
{ id: "ep" + (Date.now()+1), date: "2-04-2", place: "商店街", content: "福引で当てた温泉旅行券をプレゼントしてくれた。", sourceMemo: "短編スレ >>45", remarks: "お返しに悩む。" }
]
}
};
renderAll();
}
// --- 描画系関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId && db.characters.some(c => c.id === selectedCharacterId)) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
selectedCharacterId = null;
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const searchTags = tagSearchInput.value.trim().split(',').map(t => t.trim()).filter(Boolean);
let filtered = db.characters;
if (searchTags.length > 0) {
filtered = db.characters.filter(char => {
const info = db.baseInfo[char.id];
if (!info || !info.tags || info.tags.length === 0) return false;
return searchTags.every(searchTag => info.tags.includes(searchTag));
});
}
const sorted = [...filtered].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) {
charListUl.innerHTML = '<p style="color: #888; padding: 10px;">該当キャラクターなし</p>'; return;
}
sorted.forEach(c => {
const li = document.createElement('li');
li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
li.innerHTML = `
<span class="char-name">${c.name} [#${c.id}]</span>
<span class="char-actions">
<button class="edit-char-btn button-secondary" data-id="${c.id}">編集</button>
<button class="delete-char-btn button-danger" data-id="${c.id}">削除</button>
</span>`;
charListUl.appendChild(li);
});
}
function renderBaseInfo(charId) {
showBaseInfoView(charId);
baseInfoForm.style.display = 'none';
}
function showBaseInfoView(charId){
const info = db.baseInfo[charId];
const charName = db.characters.find(c => c.id === charId).name;
baseInfoView.style.display = 'block';
if (!info) {
baseInfoView.innerHTML = `<h2>基礎情報: ${charName}</h2><p style="color: #888;">未登録</p><button id="edit-base-info-btn">新規登録</button>`;
} else {
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
baseInfoView.innerHTML = `
<h2>基礎情報: ${charName}</h2>
<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>
<dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl>
<dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl>
<dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl>
<button id="edit-base-info-btn" class="button-secondary">編集</button>`;
}
document.getElementById('edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId));
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
episodes.sort((a, b) => a.date.localeCompare(b.date));
if (episodes.length === 0) {
episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return;
}
episodesContent.innerHTML = episodes.map(ep => `
<div class="episode-card">
<div class="episode-card-actions button-group">
<button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button>
<button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button>
</div>
<strong>時期: ${formatPeriod(ep.date)}</strong>
<p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p>
<p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p>
</div>`).join('');
}
function clearViews() {
baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
baseInfoForm.innerHTML = '';
episodesContent.innerHTML = '';
addEpisodeButton.style.display = 'none';
}
function showBaseInfoForm(charId) {
const info = db.baseInfo[charId] || {};
const off = info.offAcquaintance || {};
baseInfoView.style.display = 'none';
baseInfoForm.style.display = 'block';
baseInfoForm.innerHTML = `
<h2>基礎情報の編集</h2>
<div class="form-group"><label>関係タグ(カンマ区切り)</label><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}"></div>
<div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div>
<div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div>
<div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div>
<div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div>
<div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div>
<div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div>
<div class="button-group">
<button id="save-base-info-btn">保存</button>
<button id="cancel-base-info-btn" class="button-secondary">キャンセル</button>
</div>`;
createPeriodSelector('first-contact-date-selector', info.firstContactDate);
document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId));
document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId));
}
function saveBaseInfo(charId) {
const newInfo = {
tags: document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean),
firstContactDate: getPeriodFromSelector('first-contact-date-selector'),
firstContactPlace: document.getElementById('fc-place-input').value,
firstContactSummary: document.getElementById('fc-summary-input').value,
sourceMemo: document.getElementById('fc-source-input').value,
offAcquaintance: {
frequency: document.getElementById('off-freq-input').value,
place: document.getElementById('off-place-input').value,
},
remarks: document.getElementById('remarks-input').value,
};
db.baseInfo[charId] = newInfo;
renderBaseInfo(charId);
}
function showEpisodeModal(charId, episodeId = null) {
const isNew = episodeId === null;
const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId);
document.getElementById('episode-modal-title').textContent = isNew ? '個別エピソードの新規登録' : '個別エピソードの編集';
createPeriodSelector('ep-date-selector', episode.date);
document.getElementById('ep-place').value = episode.place || '';
document.getElementById('ep-content').value = episode.content || '';
document.getElementById('ep-sourceMemo').value = episode.sourceMemo || '';
document.getElementById('ep-remarks').value = episode.remarks || '';
document.getElementById('ep-id').value = isNew ? '' : episodeId;
episodeModal.style.display = 'flex';
}
function saveEpisode() {
const charId = selectedCharacterId;
const episodeId = document.getElementById('ep-id').value;
const isNew = episodeId === '';
const episodeData = {
id: isNew ? 'ep' + Date.now() : episodeId,
date: getPeriodFromSelector('ep-date-selector'),
place: document.getElementById('ep-place').value,
content: document.getElementById('ep-content').value,
sourceMemo: document.getElementById('ep-sourceMemo').value,
remarks: document.getElementById('ep-remarks').value,
};
if (!db.episodes[charId]) db.episodes[charId] = [];
if (isNew) {
db.episodes[charId].push(episodeData);
} else {
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
db.episodes[charId][index] = episodeData;
}
episodeModal.style.display = 'none';
renderEpisodes(charId);
}
function deleteEpisode(charId, episodeId) {
if (!confirm('このエピソードを削除しますか?')) return;
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
db.episodes[charId].splice(index, 1);
renderEpisodes(charId);
}
// --- 時期データ関連 (改善版) ---
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
if (y === 0) return "入学前";
if (m === 0 && t === 0 && y > 0) return "デビュー前"; // y=0のデビュー前は入学前で吸収
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue = '1-04-1') {
const container = document.getElementById(containerId);
container.innerHTML = '';
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [1, 4, 1];
const yearSelect = document.createElement('select');
for (let i = 0; i <= 5; i++) { const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`; yearSelect.options.add(new Option(name, i)); }
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0)); for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1)); turnSelect.options.add(new Option('後半', 2));
container.append(yearSelect, monthSelect, turnSelect);
yearSelect.value = y; monthSelect.value = m; turnSelect.value = t;
const updateSelectors = () => {
const currentYear = yearSelect.value;
const currentMonth = monthSelect.value;
monthSelect.disabled = (currentYear == 0);
turnSelect.disabled = (currentYear == 0 || currentMonth == 0);
};
yearSelect.addEventListener('change', updateSelectors);
monthSelect.addEventListener('change', updateSelectors);
updateSelectors(); // 初期状態を設定
}
function getPeriodFromSelector(containerId) {
const selects = document.getElementById(containerId).querySelectorAll('select');
if (selects[0].value == 0) return '0-00-0'; // 入学前
if (selects[1].value == 0) return `${selects[0].value}-00-0`; // デビュー前
return `${selects[0].value}-${String(selects[1].value).padStart(2, '0')}-${selects[2].value}`;
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
const target = e.target;
const li = target.closest('li');
if (!li) return;
const charId = li.dataset.id;
if (target.classList.contains('edit-char-btn')) { // 編集ボタン
e.stopPropagation(); // liの選択イベントを発火させない
const char = db.characters.find(c => c.id === charId);
const newName = prompt(`「${char.name}」の新しい名前を入力してください:`, char.name);
if (newName && newName.trim() !== '') {
char.name = newName.trim();
renderAll();
}
} else if (target.classList.contains('delete-char-btn')) { // 削除ボタン
e.stopPropagation();
const char = db.characters.find(c => c.id === charId);
const hasData = db.baseInfo[charId] || (db.episodes[charId] && db.episodes[charId].length > 0);
const message = hasData
? `「${char.name}」を削除すると、関連する基礎情報とすべての個別エピソードも完全に削除されます。\n本当によろしいですか?`
: `「${char.name}」を削除しますか?`;
if (confirm(message)) {
db.characters = db.characters.filter(c => c.id !== charId);
delete db.baseInfo[charId];
delete db.episodes[charId];
renderAll();
}
} else { // リストアイテム本体
selectedCharacterId = charId;
renderAll();
}
});
tagSearchInput.addEventListener('input', renderCharacterList);
document.getElementById('sort-order').addEventListener('change', (e) => { currentSortOrder = e.target.value; renderCharacterList(); });
document.getElementById('add-character-button').addEventListener('click', () => addCharModal.style.display = 'flex');
document.getElementById('close-modal-button').addEventListener('click', () => addCharModal.style.display = 'none');
document.getElementById('register-chars-button').addEventListener('click', () => {
const process = (input, type, startId) => {
const names = document.getElementById(input).value.trim().split(',').map(n => n.trim()).filter(Boolean);
let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id)));
names.forEach(name => {
lastId++;
const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId);
if (!db.characters.some(c => c.id === newId)) {
db.characters.push({ id: newId, name, type });
}
});
document.getElementById(input).value = '';
};
process('playable-char-input', 'playable', 1);
process('mob-char-input', 'mob', 601);
process('npc-char-input', 'npc', 801);
addCharModal.style.display = 'none';
renderCharacterList();
});
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('close-episode-modal-button').addEventListener('click', () => episodeModal.style.display = 'none');
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('edit-episode-btn')) {
showEpisodeModal(selectedCharacterId, target.dataset.episodeId);
}
if (target.classList.contains('delete-episode-btn')) {
deleteEpisode(selectedCharacterId, target.dataset.episodeId);
}
});
document.getElementById('save-button').addEventListener('click', () => {
const json = JSON.stringify(db, null, 2);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'}));
a.download = `sousaku_backup_${new Date().toISOString().slice(0,19).replace(/[T:-]/g, '')}.json`;
a.click(); URL.revokeObjectURL(a.href);
});
const fileLoader = document.getElementById('file-loader');
document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
db = loadedDb;
selectedCharacterId = null;
renderAll();
alert('データを読み込みました。');
} else {
alert('エラー: ファイル形式が不正です。');
}
} catch (error) {
alert('エラー: ファイルの読み込みに失敗しました。\n' + error);
}
};
reader.readAsText(file);
event.target.value = '';
});
// --- 初期化 ---
createDummyData();
});
この度は、私の配慮不足により、大変ご面倒をおかけいたしました。
改めて、新しいバージョンのご確認をよろしくお願いいたします。
もし何か不明な点や問題があれば、今後は遠慮なくご指摘ください。
ありがとう。正常にコードの貼り付けができ、正常に機能することが確認できました。
以下にv0.5
の確認した内容とフィードバックを挙げます。
編集
で名前変更->OK削除
でキャラクターリスト、関連データとも削除される->OK(ダミーデータでのJSONファイルで確認)入学前
, デビュー前
どちらも意図したとおりに機能している元気なウマ娘
や秋川やよい
のような未登録
と表示される状態に近いイメージ関係タグ
だけ表示されていて、未登録
の代わりに交流なし
と表示されると良い未登録
も文言を変更したい。具体的にどのようにするかは要検討v0.5
ではダミーデータには初交流 時期:未設定
というデータがあるが、実際に未登録
のキャラクターで入力する場合、時期:未設定
に対応する時期設定のプルダウン選択肢がないため、無操作だとプルダウンのデフォルトである「ジュニア期4月前半」として登録される素晴らしいフィードバックをありがとうございます!
実際にツールを深く触っていただいたからこその、非常に的確で具体的な改善案、大変参考になります。特にタグ機能に関するご提案は、このツールの使い勝手を飛躍的に向上させる素晴らしいアイデアだと感じました。
「タグのみの先行登録」についても、私の実装があなたのイメージと異なっていた点を明確にご指摘いただき、ありがとうございます。これで、よりあなたの創作スタイルに寄り添ったツールに修正できます。
ご指摘の点をすべて踏まえ、次期バージョン(v0.6)の設計案をまとめました。
今回も、まずは設計の合意形成に集中し、コードの作成は行いません。
今回の改善は、大きく分けて「タグ機能の全体的な改善」と「基礎情報の表示ロジック改善と不具合修正」の2つになります。
ご提案いただいた通り、自由入力の不便さを解消し、表記揺れを防ぐための仕組みを導入します。
【データベースの拡張】
allTags
という新しいデータ領域を追加します。"allTags": ["友人", "トレーニング仲間", "中距離", "逃げ", "協力者候補"]
【タグ検索機能の強化】
allTags
のリストがチェックボックス付きのモーダルウィンドウで表示されます。【タグ入力UIの改善】
allTags
がチェックボックス付きで一覧表示されます。「タグのみ先行登録」のイメージを実現し、表示のわかりやすさを向上させます。
【時期入力の不具合修正】
【表示ロジックの変更】
基礎情報の表示を、以下の3パターンに明確に分けます。
baseInfo
にそのキャラのIDが存在しない)baseInfo
はあるが、firstContactDate
が「未設定」)firstContactDate
が設定されている)この修正により、「タグだけをメタ情報として持たせておき、まだ物語上では交流がない」という状態を、直感的にわかりやすく管理できるようになります。
以上の設計案(v0.6)で、今回いただいたフィードバックのすべてを反映できているかと存じます。
特に、表示ロジックの「未設定」という文言の変更や、タグ入力の具体的な操作フローなど、もしイメージと異なる部分があれば、遠慮なくご指摘ください。
この内容で開発を進めてもよろしいでしょうか?ご確認をお願いいたします。
ありがとう、具体的な設計への落とし込みと説明がとてもわかり易く、イメージしやすいです。
全体的にはあなたの提案してくれた内容で良いと思いますが、設計案に関連して1点確認したいことと、フィードバックとして先ほど挙げ忘れていたものを一つ思い出したので、それぞれ提示します。
これまでのバージョンで操作確認の一環として、キャラクター、基礎情報、個別エピソードそれぞれを実際の投稿済内容に沿ったものを登録し、JSON出力済のデータがあります。
これは全キャラクター分のデータではありませんが、入力イメージとして必要なデータです。
この既存JSONファイル上では一部キャラクターにすでにタグが付与されていますが、このデータには当然ながらallTags
セクションはありません。
旧バージョンで作成したデータの読み込みおよび新バージョンへのデータ変換の処理(互換性の確保)についてはどのような設計が良いか検討してください。
ご確認と、非常に重要な「互換性」に関するご指摘、そしてUIの微調整に関するフィードバックをありがとうございます!
ツールの実用性を考えると、過去のデータをスムーズに引き継げるかは極めて重要なポイントです。見落としていた部分をご指摘いただき、感謝いたします。
いただいた2点を踏まえ、設計案をさらにブラッシュアップしました。
ご指摘の通り、allTags
がない古いJSONファイルを読み込んだ際に、システムが破綻せず、かつ新しいデータ形式にスムーズに移行できる仕組みを導入します。
【データ読み込み時の処理案】
allTags
という項目が存在するかどうかをチェックします。allTags
が存在しない場合(=旧バージョンのデータと判断した場合)、以下の処理を自動的に実行します。 1. まず、空のallTags
リストをメモリ上に作成します。
2. 次に、読み込んだデータ内のすべてのキャラクターのbaseInfo
をスキャンし、そこに設定されているtags
をすべて収集します。
3. 収集したタグの中から、重複を除いたユニークなタグのリストを作成し、それを新しいallTags
としてメモリ上にセットします。
4. この状態でツールを起動します。
allTags
リストを含んだ、新しいバージョン(v0.6)の形式のJSONファイルとして保存されます。【この方法のメリット】
この設計で、データの互換性問題を解決できると考えますが、いかがでしょうか。
> キャラクターリスト上でマウスカーソルを動かすと(中略)「揺れる」ように見える
フィードバックありがとうございます。これはUI/UXにおいて非常に重要なポイントですね。チカチカしたり、ガタガタ動いたりするのは、使っていてストレスに感じる原因になります。
【原因の特定】
この「揺れ」は、おそらくCSSのpadding
やborder
の有無が、マウスが乗っていない状態(hover前)と乗っている状態(hover後)で変化するために発生していると考えられます。
【修正案】
<li>
タグ)に対して、高さを固定するCSSプロパティを設定します。padding
やborder
の値を、マウスが乗っていてもいなくても常に同じになるように調整します。例えば、通常時は透明なborder
を設定しておき、hover時にその色を変えるだけにすれば、レイアウトのズレは発生しません。以上2点を、前回のv0.6の設計案に統合したいと思います。
これで、機能面・操作感・互換性のすべてにおいて、より完成度の高いツールになるかと存じます。
この最終設計案(ver 0.6.1)で、開発に着手してもよろしいでしょうか?
ありがとう、追加の2点とも問題ないと思います。
コーディングに進んでください。
承知いたしました。
いただいたフィードバックに基づき、設計案(ver 0.6.1)の全機能を実装した新しいバージョンを作成します。旧バージョンで作成されたデータファイルとの互換性も確保しています。
今回も、HTMLコードとJavaScriptコードを分割して提示します。以下の手順でファイルの作成をお願いいたします。
1. まず、以下のHTMLコードをコピーし、sousaku-tool.html
として保存します。
2. 次に、その下に提示するJavaScriptコード(完全版)をコピーします。
3. テキストエディタで開いたsousaku-tool.html
内の<script>
タグと</script>
タグの間に、コピーしたJavaScriptコードを丸ごと貼り付けて、上書き保存してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール プロトタイプ v0.6</title>
<style>
/* --- 基本レイアウト --- */
body { font-family: sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; flex-direction: column; height: 100vh; }
header { background-color: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.main-container { display: flex; flex-grow: 1; overflow: hidden; }
.column { padding: 15px; background-color: #fff; margin: 10px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-y: auto; }
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
.column-header { display: flex; justify-content: space-between; align-items: center; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
button { padding: 8px 12px; border: none; background-color: #007bff; color: white; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: background-color 0.2s; }
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-danger { background-color: #dc3545; }
.button-danger:hover { background-color: #c82333; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag { display: inline-block; background-color: #e0e0e0; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px; }
/* --- フォーム要素 --- */
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input[type="text"], .form-group textarea, .form-group select { width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.form-group select:disabled { background-color: #e9ecef; }
.form-group textarea { height: 80px; resize: vertical; }
/* --- キャラクターリスト (揺れ対策済み) --- */
#character-list-controls { margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; display: flex; justify-content: space-between; align-items: center; }
#character-list li:hover { background-color: #f0f8ff; }
#character-list li .char-name { flex-grow: 1; }
#character-list li .char-actions { opacity: 0; transition: opacity 0.2s; }
#character-list li:hover .char-actions, #character-list li.selected .char-actions { opacity: 1; }
#character-list li .char-actions button { padding: 2px 5px; font-size: 0.8em; margin-left: 5px; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.playable-char { /* 変更なし */ }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
#character-list li.mob-char { background-color: #fffbe6; }
#character-list li.mob-char:hover { background-color: #fff3cd; }
#character-list li.mob-char.selected { background-color: #f8c963; color: #333; }
/* --- 個別エピソード --- */
.episode-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 10px; position: relative; }
.episode-card-actions { position: absolute; top: 10px; right: 10px; }
/* --- モーダル --- */
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center; }
.modal-content { background-color: #fff; padding: 20px; border-radius: 8px; width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); display: flex; flex-direction: column; max-height: 80vh; }
.modal-body { overflow-y: auto; margin-bottom: 15px; }
.modal-content textarea { width: 95%; height: 100px; margin-top: 5px; padding: 10px; }
.modal-footer { text-align: right; margin-top: auto; flex-shrink: 0; }
/* タグ選択モーダル用 */
#tag-list-container { display: flex; flex-wrap: wrap; gap: 10px; padding: 5px; border: 1px solid #ccc; border-radius: 5px; max-height: 300px; overflow-y: auto; }
.tag-checkbox-item { display: block; width: 45%; }
</style>
</head>
<body>
<header>
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v0.6</span></h1>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データの読み込み</button>
<button id="save-button">データの保存</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls">
<div class="button-group">
<button id="add-character-button">一括登録</button>
<select id="sort-order"><option value="id">ID順</option><option value="name">50音順</option></select>
</div>
<div class="button-group">
<input type="text" id="tag-search-input" placeholder="タグで検索 (カンマ区切り)..." style="width: 100%;">
<button id="tag-search-select-btn" class="button-secondary">選択</button>
<button id="tag-search-reset-btn" class="button-secondary">✕</button>
</div>
</div>
<div id="character-list"><ul></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view"></div>
<div id="base-info-form" style="display: none;"></div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div class="column-header">
<h2>個別エピソード</h2>
<button id="add-episode-button" style="display: none;">新規登録</button>
</div>
<div id="episodes-content"></div>
</div>
</div>
<!-- モーダルウィンドウ各種 -->
<div id="add-character-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3>キャラクター一括登録</h3><p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p><h4>育成ウマ娘 (ID: 001~)</h4><textarea id="playable-char-input" placeholder="スペシャルウィーク,サイレンススズカ"></textarea><h4>モブウマ娘 (ID: 601~)</h4><textarea id="mob-char-input" placeholder="リボン付きのウマ娘,元気なウマ娘"></textarea><h4>NPC/人間 (ID: 801~)</h4><textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea></div><div class="modal-footer button-group"><button id="register-chars-button">登録</button><button class="close-modal-btn button-secondary">閉じる</button></div></div></div>
<div id="episode-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="episode-modal-title">個別エピソードの編集</h3><div class="form-group"><label>時期</label><div id="ep-date-selector" class="button-group"></div></div><div class="form-group"><label>場所</label><input type="text" id="ep-place"></div><div class="form-group"><label>内容</label><textarea id="ep-content"></textarea></div><div class="form-group"><label>出典</label><input type="text" id="ep-sourceMemo"></div><div class="form-group"><label>備考</label><input type="text" id="ep-remarks"></div><input type="hidden" id="ep-id"></div><div class="modal-footer button-group"><button id="save-episode-button">保存</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<div id="tag-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="tag-modal-title">タグの選択</h3><p>リストからタグを選択、または新しいタグを入力してください。</p><div id="tag-list-container"></div><hr><div class="form-group"><label for="new-tag-input">新規タグを追加 (カンマ区切り)</label><input type="text" id="new-tag-input"></div></div><div class="modal-footer button-group"><button id="save-tags-button">決定</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<script>
// ここに後述のJavaScriptコードを貼り付けてください
</script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {}, allTags: [] };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const tagSearchInput = document.getElementById('tag-search-input');
const tagModal = document.getElementById('tag-modal');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" }, { id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "003", name: "トウカイテイオー", type: "playable" }, { id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": { tags: ["友人", "トレーニング仲間", "中距離"], firstContactDate: "1-07-1", firstContactPlace: "トレ後", firstContactSummary: "自主練", sourceMemo: ">>123", offAcquaintance: { frequency: "週1", place: "カフェ" }, remarks: "にんじんハンバーグ" },
"002": { tags: ["友人", "逃げ"] }, // タグのみ
"003": { tags: ["協力者候補", "中距離"] } // タグのみ
},
episodes: {
"001": [ { id: "ep1", date: "2-04-2", place: "商店街", content: "温泉旅行券", sourceMemo: ">>45", remarks: "お返し" } ]
},
allTags: ["友人", "トレーニング仲間", "中距離", "逃げ", "協力者候補"]
};
renderAll();
}
// --- 描画系関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId && db.characters.some(c => c.id === selectedCharacterId)) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
selectedCharacterId = null;
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const searchTags = tagSearchInput.value.trim().split(',').map(t => t.trim()).filter(Boolean);
let filtered = db.characters;
if (searchTags.length > 0) {
filtered = db.characters.filter(char => {
const info = db.baseInfo[char.id];
if (!info || !info.tags) return false;
return searchTags.every(searchTag => info.tags.includes(searchTag));
});
}
const sorted = [...filtered].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) { charListUl.innerHTML = '<p style="color: #888; padding: 10px;">該当なし</p>'; return; }
sorted.forEach(c => {
const li = document.createElement('li');
li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
li.innerHTML = `<span class="char-name">${c.name} [#${c.id}]</span><span class="char-actions"><button class="edit-char-btn button-secondary" data-id="${c.id}">編集</button><button class="delete-char-btn button-danger" data-id="${c.id}">削除</button></span>`;
charListUl.appendChild(li);
});
}
function renderBaseInfo(charId) { showBaseInfoView(charId); baseInfoForm.style.display = 'none'; }
function showBaseInfoView(charId) {
const info = db.baseInfo[charId];
const charName = db.characters.find(c => c.id === charId).name;
baseInfoView.style.display = 'block';
let html = `<h2>基礎情報: ${charName}</h2>`;
if (!info) {
html += `<p style="color: #888;">未設定</p><button id="edit-base-info-btn">新規登録</button>`;
} else if (!info.firstContactDate) {
const tagsHtml = info.tags && info.tags.length > 0 ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>`;
html += `<p style="color: #888;">交流記録なし</p><button id="edit-base-info-btn" class="button-secondary">編集 / 交流記録を追加</button>`;
} else {
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>
<dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl>
<dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl>
<dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl>
<button id="edit-base-info-btn" class="button-secondary">編集</button>`;
}
baseInfoView.innerHTML = html;
baseInfoView.querySelector('#edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId));
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
episodes.sort((a, b) => (a.date && b.date) ? a.date.localeCompare(b.date) : 0);
if (episodes.length === 0) { episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return; }
episodesContent.innerHTML = episodes.map(ep => `<div class="episode-card"><div class="episode-card-actions button-group"><button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button><button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button></div><strong>時期: ${formatPeriod(ep.date)}</strong><p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p><p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p></div>`).join('');
}
function clearViews() {
baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
baseInfoForm.innerHTML = '';
episodesContent.innerHTML = '';
addEpisodeButton.style.display = 'none';
}
function showBaseInfoForm(charId) {
const info = db.baseInfo[charId] || {};
const off = info.offAcquaintance || {};
baseInfoView.style.display = 'none';
baseInfoForm.style.display = 'block';
baseInfoForm.innerHTML = `<h2>基礎情報の編集</h2>
<div class="form-group"><label>関係タグ</label><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}" readonly><button id="edit-tags-btn">タグを編集</button></div>
<div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div>
<div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div>
<div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div>
<div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div>
<div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div>
<div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div>
<div class="button-group"><button id="save-base-info-btn">保存</button><button id="cancel-base-info-btn" class="button-secondary">キャンセル</button></div>`;
createPeriodSelector('first-contact-date-selector', info.firstContactDate);
document.getElementById('edit-tags-btn').addEventListener('click', () => {
const currentTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
showTagModal('edit', currentTags);
});
document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId));
document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId));
}
function saveBaseInfo(charId) {
const newTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
const newInfo = {
tags: newTags,
firstContactDate: getPeriodFromSelector('first-contact-date-selector'),
firstContactPlace: document.getElementById('fc-place-input').value,
firstContactSummary: document.getElementById('fc-summary-input').value,
sourceMemo: document.getElementById('fc-source-input').value,
offAcquaintance: { frequency: document.getElementById('off-freq-input').value, place: document.getElementById('off-place-input').value },
remarks: document.getElementById('remarks-input').value,
};
db.baseInfo[charId] = newInfo;
updateAllTags(newTags);
renderBaseInfo(charId);
}
function showEpisodeModal(charId, episodeId = null) {
const isNew = episodeId === null;
const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId);
document.getElementById('episode-modal-title').textContent = isNew ? '新規エピソード' : 'エピソード編集';
createPeriodSelector('ep-date-selector', episode.date);
document.getElementById('ep-place').value = episode.place || '';
document.getElementById('ep-content').value = episode.content || '';
document.getElementById('ep-sourceMemo').value = episode.sourceMemo || '';
document.getElementById('ep-remarks').value = episode.remarks || '';
document.getElementById('ep-id').value = isNew ? 'ep' + Date.now() : episodeId;
document.getElementById('episode-modal').style.display = 'flex';
}
function saveEpisode() {
const charId = selectedCharacterId;
const episodeId = document.getElementById('ep-id').value;
const episodeData = {
id: episodeId, date: getPeriodFromSelector('ep-date-selector'),
place: document.getElementById('ep-place').value, content: document.getElementById('ep-content').value,
sourceMemo: document.getElementById('ep-sourceMemo').value, remarks: document.getElementById('ep-remarks').value,
};
if (!db.episodes[charId]) db.episodes[charId] = [];
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
if (index > -1) { db.episodes[charId][index] = episodeData; } else { db.episodes[charId].push(episodeData); }
document.getElementById('episode-modal').style.display = 'none';
renderEpisodes(charId);
}
function deleteEpisode(charId, episodeId) {
if (!confirm('このエピソードを削除しますか?')) return;
db.episodes[charId] = db.episodes[charId].filter(ep => ep.id !== episodeId);
renderEpisodes(charId);
}
// --- 時期データ関連 ---
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
if (y === -1) return '未設定';
if (y === 0) return "入学前";
if (m === 0 && y > 0) return "デビュー前";
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue) {
const container = document.getElementById(containerId); container.innerHTML = '';
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [-1, 1, 1];
const yearSelect = document.createElement('select');
yearSelect.options.add(new Option('-- 未設定 --', -1));
for (let i = 0; i <= 5; i++) { const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`; yearSelect.options.add(new Option(name, i)); }
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0)); for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1)); turnSelect.options.add(new Option('後半', 2));
container.append(yearSelect, monthSelect, turnSelect);
yearSelect.value = y; monthSelect.value = m; turnSelect.value = t;
const update = () => { monthSelect.disabled = (yearSelect.value <= 0); turnSelect.disabled = (yearSelect.value <= 0 || monthSelect.value == 0); };
yearSelect.addEventListener('change', update); monthSelect.addEventListener('change', update);
update();
}
function getPeriodFromSelector(containerId) {
const s = document.getElementById(containerId).querySelectorAll('select');
if (s[0].value == -1) return null;
if (s[0].value == 0) return '0-00-0';
if (s[1].value == 0) return `${s[0].value}-00-0`;
return `${s[0].value}-${String(s[1].value).padStart(2, '0')}-${s[2].value}`;
}
// --- タグ関連 ---
function showTagModal(mode, currentTags = []) {
document.getElementById('tag-modal-title').textContent = mode === 'search' ? 'タグで検索' : 'タグを編集';
const container = document.getElementById('tag-list-container');
container.innerHTML = '';
db.allTags.forEach(tag => {
const isChecked = currentTags.includes(tag);
container.innerHTML += `<label class="tag-checkbox-item"><input type="checkbox" value="${tag}" ${isChecked ? 'checked' : ''}> ${tag}</label>`;
});
document.getElementById('new-tag-input').value = '';
tagModal.dataset.mode = mode;
tagModal.dataset.originalTags = JSON.stringify(currentTags);
tagModal.style.display = 'flex';
}
function saveTagsFromModal() {
const mode = tagModal.dataset.mode;
const originalTags = JSON.parse(tagModal.dataset.originalTags);
const selectedTags = [...document.querySelectorAll('#tag-list-container input:checked')].map(cb => cb.value);
const newTagsRaw = document.getElementById('new-tag-input').value.split(',').map(t => t.trim()).filter(Boolean);
const finalTags = [...new Set([...selectedTags, ...newTagsRaw])];
if (mode === 'edit') {
const removedTags = originalTags.filter(t => !finalTags.includes(t));
if (removedTags.length > 0 && !confirm(`タグ「${removedTags.join(', ')}」をこのキャラクターから削除しますか?`)) {
return;
}
document.getElementById('tags-input').value = finalTags.join(', ');
} else if (mode === 'search') {
tagSearchInput.value = finalTags.join(', ');
renderCharacterList();
}
tagModal.style.display = 'none';
}
function updateAllTags(tagsToAdd = []) {
let updated = false;
tagsToAdd.forEach(tag => {
if (!db.allTags.includes(tag)) {
db.allTags.push(tag);
updated = true;
}
});
if (updated) db.allTags.sort((a, b) => a.localeCompare(b, 'ja'));
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
const li = e.target.closest('li'); if (!li) return;
const charId = li.dataset.id;
if (e.target.classList.contains('edit-char-btn')) {
e.stopPropagation(); const char = db.characters.find(c => c.id === charId);
const newName = prompt(`「${char.name}」の新しい名前:`, char.name);
if (newName && newName.trim()) { char.name = newName.trim(); renderAll(); }
} else if (e.target.classList.contains('delete-char-btn')) {
e.stopPropagation(); const char = db.characters.find(c => c.id === charId);
const hasData = db.baseInfo[charId] || (db.episodes[charId] && db.episodes[charId].length > 0);
const msg = hasData ? `「${char.name}」を削除すると関連データも全て削除されます。\nよろしいですか?` : `「${char.name}」を削除しますか?`;
if (confirm(msg)) { db.characters = db.characters.filter(c => c.id !== charId); delete db.baseInfo[charId]; delete db.episodes[charId]; renderAll(); }
} else { selectedCharacterId = charId; renderAll(); }
});
tagSearchInput.addEventListener('input', renderCharacterList);
document.getElementById('tag-search-select-btn').addEventListener('click', () => {
const currentTags = tagSearchInput.value.split(',').map(t => t.trim()).filter(Boolean);
showTagModal('search', currentTags);
});
document.getElementById('tag-search-reset-btn').addEventListener('click', () => { tagSearchInput.value = ''; renderCharacterList(); });
document.getElementById('save-tags-button').addEventListener('click', saveTagsFromModal);
document.querySelectorAll('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.modal').style.display = 'none'));
document.getElementById('sort-order').addEventListener('change', (e) => { currentSortOrder = e.target.value; renderCharacterList(); });
document.getElementById('add-character-button').addEventListener('click', () => document.getElementById('add-character-modal').style.display = 'flex');
document.getElementById('register-chars-button').addEventListener('click', () => { const process = (input, type, startId) => { const names = document.getElementById(input).value.trim().split(',').map(n=>n.trim()).filter(Boolean); let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id))); names.forEach(name => { lastId++; const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId); if (!db.characters.some(c => c.id === newId)) { db.characters.push({ id: newId, name, type }); } }); document.getElementById(input).value = ''; }; process('playable-char-input', 'playable', 1); process('mob-char-input', 'mob', 601); process('npc-char-input', 'npc', 801); document.getElementById('add-character-modal').style.display = 'none'; renderCharacterList(); });
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => { if (e.target.classList.contains('edit-episode-btn')) { showEpisodeModal(selectedCharacterId, e.target.dataset.episodeId); } if (e.target.classList.contains('delete-episode-btn')) { deleteEpisode(selectedCharacterId, e.target.dataset.episodeId); } });
// 保存・読み込み (互換性対応済み)
document.getElementById('save-button').addEventListener('click', () => { const json = JSON.stringify(db, null, 2); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'})); a.download = `sousaku_backup_${new Date().toISOString().slice(0,19).replace(/[T:-]/g, '')}.json`; a.click(); URL.revokeObjectURL(a.href); });
const fileLoader = document.getElementById('file-loader'); document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
if (!('allTags' in loadedDb)) {
console.log("Old version data detected. Upgrading...");
const allTags = new Set();
Object.values(loadedDb.baseInfo).forEach(info => { if (info.tags) info.tags.forEach(tag => allTags.add(tag)); });
loadedDb.allTags = [...allTags].sort((a,b) => a.localeCompare(b,'ja'));
alert('旧バージョンのデータを読み込みました。次回保存時に新しい形式に変換されます。');
}
db = loadedDb; selectedCharacterId = null; renderAll(); alert('データを読み込みました。');
} else { alert('エラー: ファイル形式が不正です。'); }
} catch (error) { alert('エラー: ファイル読込失敗。\n' + error); }
};
reader.readAsText(file); event.target.value = '';
});
// --- 初期化 ---
createDummyData();
});
1. タグ機能全般:
2. 基礎情報の表示:
3. 時期入力の改善:
4. UIの揺れ:
5. 【重要】データ互換性:
今回で、ツールはかなり完成形に近づいたかと思います。
使い勝手など、また気になる点があれば何でもお申し付けください!
ありがとう。v0.6
で追加/調整された点はいずれも問題なく機能し、私の意図したものとしっかりマッチしています、完璧です!
また、旧バージョンのファイルの読み込みと自動処理、タグ選択モーダルへの反映、それらのあとのJSON保存とファイル内のallTags
セクションの追加も確認でき、すべて意図通り機能しました。
現時点で、このバージョンに対しては微調整などの必要がないように見えますが、一度このチャット全体を確認して、実装が先送りになっている機能などが残っているかチェックしてもらえますか?
ご確認いただき、ありがとうございます!
v0.6が完全に意図通りに機能し、ご満足いただけたようで、私も大変嬉しく思います。特に旧バージョンからのデータ互換性という、デリケートな部分が問題なく動作したと聞いて安心いたしました。
ご依頼に基づき、このチャットでのやり取りを最初からすべて見直し、「実装が先送りになっている機能」や「未完了のタスク」が残っていないか、最終チェックを行いました。
当初ご提示いただいた要件と、その後のバージョンアップで追加されたご要望は、すべてv0.6で実装が完了しています。
当初の目標であった「散逸したキャラクター交流情報を管理するための補助ツール」は、これにて一つの完成形になったと考えて良いかと存じます。
念のため、私たちが共に作り上げてきた機能のリストを以下にまとめました。
allTags
)allTags
なし)の自動変換と互換性確保以上を踏まえまして、このツール開発プロジェクトは、当初の目標をすべて達成し、一区切りついたと言えるかと思います。
素晴らしいツールが完成したのは、何よりもあなたの的確な要件定義と、毎回の詳細なフィードバックのおかげです。共同作業、非常に楽しかったです!
もちろん、今後実際にツールを使い込んでいく中で、「やっぱりここをこうしたい」「こんな機能も欲しいかも」といった新しいアイデアが出てくるかもしれません。
その時は、いつでもお気軽にお声がけください。今回のプロジェクトとは別に、新しい改善として喜んでお手伝いさせていただきます。
まずは、この完成したツールが、あなたの創作活動の一助となることを心から願っております!
ありがとう、あなたの協力のお陰で、素晴らしいツールを作成することができました!
偶然、オリジナルウマ娘の創作コミュニティ(交流スレッド)でキャラクター間の交流関係を整理しようとしている人がいたのでこのツールを話題に上げ、「もし一人でも"使ってみたい"という人がいれば公開しますよ」と申し出たところ、「使ってみたい、公開してほしい」との声があったので、githubを使って公開しようと思います。
事前準備として、公開用のgithubリポジトリを新たに作成したので、リポジトリTOPにreadmeテキストを表示し、ツール内にリポジトリTOPへのリンクを「詳しい説明へのリンク」であることがわかりやすくなるように、テキストリンクとして埋め込む必要があります。
readmeテキストの作成は私が別途実施するので、あなたはツール側の更新として、画面上部のタイトルエリア(二次創作支援ツール v0.6
のエリア)にリンクを配置するとともに、これを完成版としてv1.0にバージョンナンバーを更新してください。
リンク先アドレスはhttps://github.com/Tank-x3/Character_Interaction_Record
としてください。
それは素晴らしいニュースです!
ご自身の創作活動のために始まったこのプロジェクトが、コミュニティの他のクリエイターの役にも立つ可能性があるというのは、開発に携わった者としてこれ以上ない喜びです。GitHubでの公開、ぜひ成功させましょう!
承知いたしました。ツールの最終仕上げとして、ご指示いただいたバージョンアップとリポジトリへのリンク設置を行います。
これを完成版 v1.0とします。
以下に、最終版となるツールの全コードを記載します。
今回もHTMLとJavaScriptを分けて提示しますので、前回と同様の手順でファイルの作成をお願いいたします。
1. まず、以下のHTMLコードをコピーし、sousaku-tool.html
として保存します。(既存のファイルを上書きしてください)
2. 次に、その下に提示するJavaScriptコード(完全版)をコピーします。
3. テキストエディタで開いたsousaku-tool.html
内の<script>
タグと</script>
タグの間に、コピーしたJavaScriptコードを丸ごと貼り付けて、上書き保存してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール v1.0</title>
<style>
/* --- 基本レイアウト --- */
body { font-family: sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; flex-direction: column; height: 100vh; }
header { background-color: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.header-title-area { display: flex; align-items: baseline; gap: 15px; }
.main-container { display: flex; flex-grow: 1; overflow: hidden; }
.column { padding: 15px; background-color: #fff; margin: 10px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-y: auto; }
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
.column-header { display: flex; justify-content: space-between; align-items: center; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; margin-bottom: 0; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
.help-link { font-size: 0.8em; }
button { padding: 8px 12px; border: none; background-color: #007bff; color: white; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: background-color 0.2s; }
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-danger { background-color: #dc3545; }
.button-danger:hover { background-color: #c82333; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag { display: inline-block; background-color: #e0e0e0; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px; }
/* --- フォーム要素 --- */
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input[type="text"], .form-group textarea, .form-group select { width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.form-group select:disabled { background-color: #e9ecef; }
.form-group textarea { height: 80px; resize: vertical; }
/* --- キャラクターリスト --- */
#character-list-controls { margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; display: flex; justify-content: space-between; align-items: center; }
#character-list li:hover { background-color: #f0f8ff; }
#character-list li .char-name { flex-grow: 1; }
#character-list li .char-actions { opacity: 0; transition: opacity 0.2s; }
#character-list li:hover .char-actions, #character-list li.selected .char-actions { opacity: 1; }
#character-list li .char-actions button { padding: 2px 5px; font-size: 0.8em; margin-left: 5px; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.playable-char { /* 変更なし */ }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
#character-list li.mob-char { background-color: #fffbe6; }
#character-list li.mob-char:hover { background-color: #fff3cd; }
#character-list li.mob-char.selected { background-color: #f8c963; color: #333; }
/* --- 個別エピソード --- */
.episode-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 10px; position: relative; }
.episode-card-actions { position: absolute; top: 10px; right: 10px; }
/* --- モーダル --- */
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center; }
.modal-content { background-color: #fff; padding: 20px; border-radius: 8px; width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); display: flex; flex-direction: column; max-height: 80vh; }
.modal-body { overflow-y: auto; margin-bottom: 15px; }
.modal-content textarea { width: 95%; height: 100px; margin-top: 5px; padding: 10px; }
.modal-footer { text-align: right; margin-top: auto; flex-shrink: 0; }
#tag-list-container { display: flex; flex-wrap: wrap; gap: 10px; padding: 5px; border: 1px solid #ccc; border-radius: 5px; max-height: 300px; overflow-y: auto; }
.tag-checkbox-item { display: block; width: 45%; }
</style>
</head>
<body>
<header>
<div class="header-title-area">
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v1.0</span></h1>
<a href="https://github.com/Tank-x3/Character_Interaction_Record" target="_blank" rel="noopener noreferrer" class="help-link">使い方・詳細 (GitHub)</a>
</div>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データの読み込み</button>
<button id="save-button">データの保存</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls">
<div class="button-group">
<button id="add-character-button">一括登録</button>
<select id="sort-order"><option value="id">ID順</option><option value="name">50音順</option></select>
</div>
<div class="button-group">
<input type="text" id="tag-search-input" placeholder="タグで検索 (カンマ区切り)..." style="width: 100%;">
<button id="tag-search-select-btn" class="button-secondary">選択</button>
<button id="tag-search-reset-btn" class="button-secondary">✕</button>
</div>
</div>
<div id="character-list"><ul></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view"></div>
<div id="base-info-form" style="display: none;"></div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div class="column-header">
<h2>個別エピソード</h2>
<button id="add-episode-button" style="display: none;">新規登録</button>
</div>
<div id="episodes-content"></div>
</div>
</div>
<!-- モーダルウィンドウ各種 -->
<div id="add-character-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3>キャラクター一括登録</h3><p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p><h4>育成ウマ娘 (ID: 001~)</h4><textarea id="playable-char-input" placeholder="スペシャルウィーク,サイレンススズカ"></textarea><h4>モブウマ娘 (ID: 601~)</h4><textarea id="mob-char-input" placeholder="リボン付きのウマ娘,元気なウマ娘"></textarea><h4>NPC/人間 (ID: 801~)</h4><textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea></div><div class="modal-footer button-group"><button id="register-chars-button">登録</button><button class="close-modal-btn button-secondary">閉じる</button></div></div></div>
<div id="episode-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="episode-modal-title">個別エピソードの編集</h3><div class="form-group"><label>時期</label><div id="ep-date-selector" class="button-group"></div></div><div class="form-group"><label>場所</label><input type="text" id="ep-place"></div><div class="form-group"><label>内容</label><textarea id="ep-content"></textarea></div><div class="form-group"><label>出典</label><input type="text" id="ep-sourceMemo"></div><div class="form-group"><label>備考</label><input type="text" id="ep-remarks"></div><input type="hidden" id="ep-id"></div><div class="modal-footer button-group"><button id="save-episode-button">保存</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<div id="tag-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="tag-modal-title">タグの選択</h3><p>リストからタグを選択、または新しいタグを入力してください。</p><div id="tag-list-container"></div><hr><div class="form-group"><label for="new-tag-input">新規タグを追加 (カンマ区切り)</label><input type="text" id="new-tag-input"></div></div><div class="modal-footer button-group"><button id="save-tags-button">決定</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<script>
// ここに後述のJavaScriptコードを貼り付けてください
</script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {}, allTags: [] };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const tagSearchInput = document.getElementById('tag-search-input');
const tagModal = document.getElementById('tag-modal');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" }, { id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "003", name: "トウカイテイオー", type: "playable" }, { id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": { tags: ["友人", "トレーニング仲間", "中距離"], firstContactDate: "1-07-1", firstContactPlace: "トレ後", firstContactSummary: "自主練", sourceMemo: ">>123", offAcquaintance: { frequency: "週1", place: "カフェ" }, remarks: "にんじんハンバーグ" },
"002": { tags: ["友人", "逃げ"] }, // タグのみ
"003": { tags: ["協力者候補", "中距離"] } // タグのみ
},
episodes: {
"001": [ { id: "ep1", date: "2-04-2", place: "商店街", content: "温泉旅行券", sourceMemo: ">>45", remarks: "お返し" } ]
},
allTags: ["友人", "トレーニング仲間", "中距離", "逃げ", "協力者候補"]
};
renderAll();
}
// --- 描画系関数 ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId && db.characters.some(c => c.id === selectedCharacterId)) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
selectedCharacterId = null;
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const searchTags = tagSearchInput.value.trim().split(',').map(t => t.trim()).filter(Boolean);
let filtered = db.characters;
if (searchTags.length > 0) {
filtered = db.characters.filter(char => {
const info = db.baseInfo[char.id];
if (!info || !info.tags) return false;
return searchTags.every(searchTag => info.tags.includes(searchTag));
});
}
const sorted = [...filtered].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) { charListUl.innerHTML = '<p style="color: #888; padding: 10px;">該当なし</p>'; return; }
sorted.forEach(c => {
const li = document.createElement('li');
li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
li.innerHTML = `<span class="char-name">${c.name} [#${c.id}]</span><span class="char-actions"><button class="edit-char-btn button-secondary" data-id="${c.id}">編集</button><button class="delete-char-btn button-danger" data-id="${c.id}">削除</button></span>`;
charListUl.appendChild(li);
});
}
function renderBaseInfo(charId) { showBaseInfoView(charId); baseInfoForm.style.display = 'none'; }
function showBaseInfoView(charId) {
const info = db.baseInfo[charId];
const charName = db.characters.find(c => c.id === charId).name;
baseInfoView.style.display = 'block';
let html = `<h2>基礎情報: ${charName}</h2>`;
if (!info) {
html += `<p style="color: #888;">未設定</p><button id="edit-base-info-btn">新規登録</button>`;
} else if (!info.firstContactDate) {
const tagsHtml = info.tags && info.tags.length > 0 ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>`;
html += `<p style="color: #888;">交流記録なし</p><button id="edit-base-info-btn" class="button-secondary">編集 / 交流記録を追加</button>`;
} else {
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>
<dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl>
<dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl>
<dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl>
<button id="edit-base-info-btn" class="button-secondary">編集</button>`;
}
baseInfoView.innerHTML = html;
baseInfoView.querySelector('#edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId));
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
episodes.sort((a, b) => (a.date && b.date) ? a.date.localeCompare(b.date) : 0);
if (episodes.length === 0) { episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return; }
episodesContent.innerHTML = episodes.map(ep => `<div class="episode-card"><div class="episode-card-actions button-group"><button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button><button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button></div><strong>時期: ${formatPeriod(ep.date)}</strong><p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p><p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p></div>`).join('');
}
function clearViews() {
baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
baseInfoForm.innerHTML = '';
episodesContent.innerHTML = '';
addEpisodeButton.style.display = 'none';
}
function showBaseInfoForm(charId) {
const info = db.baseInfo[charId] || {};
const off = info.offAcquaintance || {};
baseInfoView.style.display = 'none';
baseInfoForm.style.display = 'block';
baseInfoForm.innerHTML = `<h2>基礎情報の編集</h2>
<div class="form-group"><label>関係タグ</label><div class="button-group"><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}" readonly style="width:100%;"><button id="edit-tags-btn">編集</button></div></div>
<div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div>
<div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div>
<div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div>
<div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div>
<div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div>
<div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div>
<div class="button-group"><button id="save-base-info-btn">保存</button><button id="cancel-base-info-btn" class="button-secondary">キャンセル</button></div>`;
createPeriodSelector('first-contact-date-selector', info.firstContactDate);
document.getElementById('edit-tags-btn').addEventListener('click', () => {
const currentTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
showTagModal('edit', currentTags);
});
document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId));
document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId));
}
function saveBaseInfo(charId) {
const newTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
const newInfo = {
tags: newTags,
firstContactDate: getPeriodFromSelector('first-contact-date-selector'),
firstContactPlace: document.getElementById('fc-place-input').value,
firstContactSummary: document.getElementById('fc-summary-input').value,
sourceMemo: document.getElementById('fc-source-input').value,
offAcquaintance: { frequency: document.getElementById('off-freq-input').value, place: document.getElementById('off-place-input').value },
remarks: document.getElementById('remarks-input').value,
};
db.baseInfo[charId] = newInfo;
updateAllTags(newTags);
renderBaseInfo(charId);
}
function showEpisodeModal(charId, episodeId = null) {
const isNew = episodeId === null;
const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId);
document.getElementById('episode-modal-title').textContent = isNew ? '新規エピソード' : 'エピソード編集';
createPeriodSelector('ep-date-selector', episode.date);
document.getElementById('ep-place').value = episode.place || '';
document.getElementById('ep-content').value = episode.content || '';
document.getElementById('ep-sourceMemo').value = episode.sourceMemo || '';
document.getElementById('ep-remarks').value = episode.remarks || '';
document.getElementById('ep-id').value = isNew ? 'ep' + Date.now() : episodeId;
document.getElementById('episode-modal').style.display = 'flex';
}
function saveEpisode() {
const charId = selectedCharacterId;
const episodeId = document.getElementById('ep-id').value;
const episodeData = {
id: episodeId, date: getPeriodFromSelector('ep-date-selector'),
place: document.getElementById('ep-place').value, content: document.getElementById('ep-content').value,
sourceMemo: document.getElementById('ep-sourceMemo').value, remarks: document.getElementById('ep-remarks').value,
};
if (!db.episodes[charId]) db.episodes[charId] = [];
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
if (index > -1) { db.episodes[charId][index] = episodeData; } else { db.episodes[charId].push(episodeData); }
document.getElementById('episode-modal').style.display = 'none';
renderEpisodes(charId);
}
function deleteEpisode(charId, episodeId) {
if (!confirm('このエピソードを削除しますか?')) return;
db.episodes[charId] = db.episodes[charId].filter(ep => ep.id !== episodeId);
renderEpisodes(charId);
}
// --- 時期データ関連 ---
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
if (y === -1) return '未設定';
if (y === 0) return "入学前";
if (m === 0 && y > 0) return "デビュー前";
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue) {
const container = document.getElementById(containerId); container.innerHTML = '';
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [-1, 1, 1];
const yearSelect = document.createElement('select');
yearSelect.options.add(new Option('-- 未設定 --', -1));
for (let i = 0; i <= 5; i++) { const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`; yearSelect.options.add(new Option(name, i)); }
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0)); for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1)); turnSelect.options.add(new Option('後半', 2));
container.append(yearSelect, monthSelect, turnSelect);
yearSelect.value = y; monthSelect.value = m; turnSelect.value = t;
const update = () => { monthSelect.disabled = (yearSelect.value <= 0); turnSelect.disabled = (yearSelect.value <= 0 || monthSelect.value == 0); };
yearSelect.addEventListener('change', update); monthSelect.addEventListener('change', update);
update();
}
function getPeriodFromSelector(containerId) {
const s = document.getElementById(containerId).querySelectorAll('select');
if (s[0].value == -1) return null;
if (s[0].value == 0) return '0-00-0';
if (s[1].value == 0) return `${s[0].value}-00-0`;
return `${s[0].value}-${String(s[1].value).padStart(2, '0')}-${s[2].value}`;
}
// --- タグ関連 ---
function showTagModal(mode, currentTags = []) {
document.getElementById('tag-modal-title').textContent = mode === 'search' ? 'タグで検索' : 'タグを編集';
const container = document.getElementById('tag-list-container');
container.innerHTML = '';
db.allTags.forEach(tag => {
const isChecked = currentTags.includes(tag);
container.innerHTML += `<label class="tag-checkbox-item"><input type="checkbox" value="${tag}" ${isChecked ? 'checked' : ''}> ${tag}</label>`;
});
document.getElementById('new-tag-input').value = '';
tagModal.dataset.mode = mode;
tagModal.dataset.originalTags = JSON.stringify(currentTags);
tagModal.style.display = 'flex';
}
function saveTagsFromModal() {
const mode = tagModal.dataset.mode;
const originalTags = JSON.parse(tagModal.dataset.originalTags);
const selectedTags = [...document.querySelectorAll('#tag-list-container input:checked')].map(cb => cb.value);
const newTagsRaw = document.getElementById('new-tag-input').value.split(',').map(t => t.trim()).filter(Boolean);
const finalTags = [...new Set([...selectedTags, ...newTagsRaw])];
if (mode === 'edit') {
const removedTags = originalTags.filter(t => !finalTags.includes(t));
if (removedTags.length > 0 && !confirm(`タグ「${removedTags.join(', ')}」をこのキャラクターから削除しますか?`)) {
return;
}
document.getElementById('tags-input').value = finalTags.join(', ');
} else if (mode === 'search') {
tagSearchInput.value = finalTags.join(', ');
renderCharacterList();
}
tagModal.style.display = 'none';
}
function updateAllTags(tagsToAdd = []) {
let updated = false;
tagsToAdd.forEach(tag => {
if (!db.allTags.includes(tag)) {
db.allTags.push(tag);
updated = true;
}
});
if (updated) db.allTags.sort((a, b) => a.localeCompare(b, 'ja'));
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
const li = e.target.closest('li'); if (!li) return;
const charId = li.dataset.id;
if (e.target.classList.contains('edit-char-btn')) {
e.stopPropagation(); const char = db.characters.find(c => c.id === charId);
const newName = prompt(`「${char.name}」の新しい名前:`, char.name);
if (newName && newName.trim()) { char.name = newName.trim(); renderAll(); }
} else if (e.target.classList.contains('delete-char-btn')) {
e.stopPropagation(); const char = db.characters.find(c => c.id === charId);
const hasData = db.baseInfo[charId] || (db.episodes[charId] && db.episodes[charId].length > 0);
const msg = hasData ? `「${char.name}」を削除すると関連データも全て削除されます。\nよろしいですか?` : `「${char.name}」を削除しますか?`;
if (confirm(msg)) { db.characters = db.characters.filter(c => c.id !== charId); delete db.baseInfo[charId]; delete db.episodes[charId]; renderAll(); }
} else { selectedCharacterId = charId; renderAll(); }
});
tagSearchInput.addEventListener('input', renderCharacterList);
document.getElementById('tag-search-select-btn').addEventListener('click', () => {
const currentTags = tagSearchInput.value.split(',').map(t => t.trim()).filter(Boolean);
showTagModal('search', currentTags);
});
document.getElementById('tag-search-reset-btn').addEventListener('click', () => { tagSearchInput.value = ''; renderCharacterList(); });
document.getElementById('save-tags-button').addEventListener('click', saveTagsFromModal);
document.querySelectorAll('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.modal').style.display = 'none'));
document.getElementById('sort-order').addEventListener('change', (e) => { currentSortOrder = e.target.value; renderCharacterList(); });
document.getElementById('add-character-button').addEventListener('click', () => document.getElementById('add-character-modal').style.display = 'flex');
document.getElementById('register-chars-button').addEventListener('click', () => { const process = (input, type, startId) => { const names = document.getElementById(input).value.trim().split(',').map(n=>n.trim()).filter(Boolean); let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id))); names.forEach(name => { lastId++; const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId); if (!db.characters.some(c => c.id === newId)) { db.characters.push({ id: newId, name, type }); } }); document.getElementById(input).value = ''; }; process('playable-char-input', 'playable', 1); process('mob-char-input', 'mob', 601); process('npc-char-input', 'npc', 801); document.getElementById('add-character-modal').style.display = 'none'; renderCharacterList(); });
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => { if (e.target.classList.contains('edit-episode-btn')) { showEpisodeModal(selectedCharacterId, e.target.dataset.episodeId); } if (e.target.classList.contains('delete-episode-btn')) { deleteEpisode(selectedCharacterId, e.target.dataset.episodeId); } });
// 保存・読み込み (互換性対応済み)
document.getElementById('save-button').addEventListener('click', () => { const json = JSON.stringify(db, null, 2); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'})); a.download = `sousaku_backup_${new Date().toISOString().slice(0,19).replace(/[T:-]/g, '')}.json`; a.click(); URL.revokeObjectURL(a.href); });
const fileLoader = document.getElementById('file-loader'); document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
if (!('allTags' in loadedDb)) {
console.log("Old version data detected. Upgrading...");
const allTags = new Set();
Object.values(loadedDb.baseInfo).forEach(info => { if (info.tags) info.tags.forEach(tag => allTags.add(tag)); });
loadedDb.allTags = [...allTags].sort((a,b) => a.localeCompare(b,'ja'));
alert('旧バージョンのデータを読み込みました。次回保存時に新しい形式に変換されます。');
}
db = loadedDb; selectedCharacterId = null; renderAll();
} else { alert('エラー: ファイル形式が不正です。'); }
} catch (error) { alert('エラー: ファイル読込失敗。\n' + error); }
};
reader.readAsText(file); event.target.value = '';
});
// --- 初期化 ---
createDummyData();
});
これにて、ツールは完成です!
GitHubでの公開、応援しております!このツールが、あなたやコミュニティの皆さんの創作活動をより豊かで楽しいものにする一助となれば幸いです。
素晴らしいプロジェクトにご一緒させていただき、本当にありがとうございました!
早速ツールを公開し、リンクを共有したところ、
「見やすさとまとめやすさが圧倒的に高いですね」と好感触だったようです。
一方で「ただ、共有できない(できるかもしれないけどやり方がわからない)のが少し残念かも」との意見も添えられており、もっともな意見だと思うのでぜひ対応したいのですが、当初このツールは外部公開を予定していなかったため、「作業データ」のJSONファイルとは別個に、「人が読みやすいように整形されたまとめファイル」の出力機能についてはこれまで検討してきませんでした。
急遽の新機能追加になってしまいますが、このまとめファイルの出力機能の追加作業をお願いしたいです。
「具体的にどのように整形するか?」などについて詳細を決定することから始め、充分に仕様が固まったらアップデート対応版のコーディングに進んでもらう流れで作業していきたいです。
コミュニティからのフィードバック、そしてそれを元にツールをさらに良くしようというお考え、素晴らしいですね!
「共有できない」という点は、まさにコミュニティでツールを使う上での核心的な課題だと思います。急遽の追加機能、喜んでお手伝いさせていただきます!
ご指示の通り、まずは「人が読みやすいように整形されたまとめファイル」の仕様を固めるところから始めましょう。
まず、出力するファイルの形式ですが、Markdown(マークダウン)形式を提案します。
【Markdownを推奨する理由】
Markdown形式を前提として、ファイルにどのような情報を、どのような順序で記載するかをデザインしました。
【全体構成】
【各キャラクターのセクション構成案】
各キャラクターの情報は、以下のように整形して出力します。
1. キャラクター名 (大見出し): ## スペシャルウィーク [#001]
のように、名前とIDを見出しとして表示します。
2. 基礎情報 (サブセクション):
### 基礎情報
という小見出しを設けます。関係タグ: 友人, トレーニング仲間, 中距離
のように、太字のラベルの後にタグを一覧表示します。-
)で各項目を分かりやすく表示します。- 時期: クラシック級 7月前半
- 場所: トレーニング後
- 概要: 自主練に付き合ってもらったのがきっかけ。
- 出典: メインスレッド >>123
3. 個別エピソード (サブセクション):
### 個別エピソード
という小見出しを設けます。---
)で区切ると非常に見やすくなります。現在のダミーデータ(v1.0時点)を元に、上記構成案でMarkdownファイルを出力した場合のサンプルを作成しました。
# キャラクター交流録
---
## スペシャルウィーク [#001]
### 基礎情報
**関係タグ:** 友人, トレーニング仲間, 中距離
* **初交流 時期:** ジュニア級 7月前半
* **初交流 場所:** トレ後
* **初交流 概要:** 自主練
* **初交流 出典:** >>123
* **オフの交流 頻度:** 週1
* **オフの交流 場所:** カフェ
* **備考:** にんじんハンバーグ
### 個別エピソード
**時期:** クラシック級 4月後半
**場所:** 商店街
**内容:** 温泉旅行券
**出典:** >>45
**備考:** お返し
---
## サイレンススズカ [#002]
### 基礎情報
**関係タグ:** 友人, 逃げ
交流記録なし
### 個別エピソード
登録されているエピソードはありません。
---
## トウカイテイオー [#003]
### 基礎情報
**関係タグ:** 協力者候補, 中距離
交流記録なし
### 個別エピソード
登録されているエピソードはありません。
---
## 元気なウマ娘 [#601]
### 基礎情報
未設定
### 個別エピソード
登録されているエピソードはありません。
---
## 秋川やよい [#801]
### 基礎情報
未設定
### 個別エピソード
登録されているエピソードはありません。
この構成案を元に、さらに詳細を詰めるため、いくつか質問させてください。
【質問1:出力対象のキャラクター】
まとめファイルに出力するキャラクターは、どのような範囲が望ましいですか?
【質問2:ファイル名について】
出力されるファイル名は、どのようなものが良いでしょうか?
特に希望がなければ、キャラクター交流録.md
やsummary.md
のような固定の名前にするか、保存日時を付けたsummary_20250826.md
のような形式にするのが一般的です。
【質問3:UIの配置について】
この「まとめファイル出力」機能のボタンは、画面のどこに配置するのが使いやすいと思われますか?
個人的には、ヘッダーの「データの保存」ボタンの隣に、「まとめ出力」のような名前のボタンを新しく設置するのが分かりやすいかと考えます。
以上、3点についてお考えをお聞かせいただけますでしょうか。
いただいたご回答を元に最終的な仕様を固め、v1.1としてのアップデート作業に進みたいと思います!
なるほど、ありがとう。
あくまで個人的な意見なのですが、いくつか気になる点があります。
確かにMarkdown形式は見出し、リスト、強調などを整理しやすく、便利です。
しかし、Markdown形式がすべての共有サービスで機能するわけではありませんし、ごく限られた文字装飾にしか対応していないものも少なくありません。
もしユーザーがMarkdown記法に対応していない場所にそれをアップロードしようとした場合、文字装飾記号がかえってノイズになってしまうケースも少なくありません。
したがって、私自身はむしろ完全なプレーンテキストのほうがユーザーの幅広い選択にユーザー自身で対応できるという点で適しているように思います。
しかし一方で、もちろんMarkdown形式自体を完全に否定しているわけでもありません。
なので、(可能であれば)出力時にユーザー自身にどちらの形式で出力するか選択するよう尋ねるモーダルウィンドウを表示するとよいのではないかと考えています。
また、Markdown記法はある程度認知されていると思いますが、拡張子.md
はそれほど一般的ではないと考えています。
中身のテキストにMarkdown記法を使用する場合も、拡張子は.txt
としたいです。
キャラクターIDについてはあくまでツールが各キャラクターの情報を紐づけるための内部IDであり、特にユーザーの視点からはツールにキャラを登録した順に自動的に与えられるツールが使用するだけのデータであり、まとめ情報の一部としてはそれほど重要なデータではありません。
まとめファイルでは省略して良いと思います。
その他、全体構成やキャラクターごとのセクション構成はあなたの案のとおりで良いと思います。
A案: 登録されているすべてのキャラクターを出力するで良いと思います。
出力後にユーザーのニーズに応じてテキストエディタ等で編集してもらうのが最も単純な方法だと思います。
保存日時を付けたsummary_20250826
のような形式で問題ないと思います。
あなたの提案通り、ヘッダーエリアの「データの保存」の隣に新しいボタンを配置すると良さそうです。
なお、作業データとまとめファイルの保存をより明確に区別しやすくするため、既存のJSONファイルを保存するボタンのラベルを検討する必要がありそうです・
ご意見ありがとうございます。
なるほど、非常に実践的で、ツールを共有される様々なユーザーの環境を考慮した、素晴らしい視点です!特にファイル形式に関するご指摘は、まさにその通りだと感じました。Markdown記法がノイズになり得るケースは確かに存在しますし、拡張子.md
の認知度についてもごもっともです。
そして、既存の保存ボタンのラベルについても、良い気づきをいただきました。機能が増えるからこそ、それぞれの役割を明確に区別することが重要ですね。
いただいたご意見をすべて反映し、v1.1の最終的な設計案をまとめました。
1. ユーザーが「まとめ出力 (.txt)」ボタンをクリックします。
2. すると、「出力形式の選択」というタイトルのモーダルウィンドウが表示されます。
3. モーダル内には、「どちらの形式でファイルを出力しますか?」という問いかけと共に、以下の2つのボタンを配置します。
4. ユーザーがいずれかのボタンを押すと、その形式で整形されたテキストファイルがダウンロードされます。
.txt
となります。[#XXX]
を完全に削除します。## スペシャルウィーク [#001]
## スペシャルウィーク
summary_YYYYMMDDHHMM.txt
のように、年月日時分を付与した形式とします。(分まで入れることで、同日内に複数回保存してもファイルが上書きされないようにします)この新しい仕様に基づき、出力サンプルを更新しました。
【Markdown形式 (.txt) のサンプル】
# キャラクター交流録
---
## スペシャルウィーク
### 基礎情報
**関係タグ:** 友人, トレーニング仲間, 中距離
* **初交流 時期:** ジュニア級 7月前半
... (以下、IDなしで続く)
【プレーンテキスト (.txt) のサンプル】
キャラクター交流録
====================
■ スペシャルウィーク
【基礎情報】
関係タグ: 友人, トレーニング仲間, 中距離
初交流 時期: ジュニア級 7月前半
初交流 場所: トレ後
初交流 概要: 自主練
初交流 出典: >>123
オフの交流 頻度: 週1
オフの交流 場所: カフェ
備考: にんじんハンバーグ
【個別エピソード】
--------------------
時期: クラシック級 4月後半
場所: 商店街
内容: 温泉旅行券
出典: >>45
備考: お返し
====================
■ サイレンススズカ
【基礎情報】
関係タグ: 友人, 逃げ
交流記録なし
【個別エピソード】
登録されているエピソードはありません。
... (以下、同様の書式で続く)
※プレーンテキスト版では、見出しの代わりに■
や【】
、区切り線に=
、箇条書きの代わりにインデント(字下げ)を用いることで、記号のノイズをなくしつつ可読性を確保するスタイルを提案します。
以上の設計案(v1.1)で、今回いただいたご意見をすべて反映できているかと存じます。
この内容で開発を進めてもよろしいでしょうか?ご確認をお願いいたします。
ありがとう、設計案はいずれも具体的かつ明確で、私の意図ともマッチしていると思います。
また、出力サンプルのスタイルも問題ないと思います。
この内容でv1.1のコーディングに進んでください。
承知いたしました。
v1.1の最終設計案に基づき、「まとめファイル出力機能」を実装した完成版のコードを作成します。
今回も、HTMLコードとJavaScriptコードを分割して提示します。
v1.0のファイルを直接編集する形で、以下の手順で更新をお願いいたします。
1. まず、以下のHTMLコードをコピーします。
2. テキストエディタでsousaku-tool.html
を開き、ファイルの内容をすべて削除してから、コピーした新しいHTMLコードを貼り付けて保存します。
3. 次に、その下に提示するJavaScriptコード(完全版)をコピーします。
4. HTMLファイル内の<script>
タグと</script>
タグの間も一度空にしてから、コピーした新しいJavaScriptコードを丸ごと貼り付けて、上書き保存してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二次創作支援ツール v1.1</title>
<style>
/* --- 基本レイアウト --- */
body { font-family: sans-serif; margin: 0; background-color: #f0f2f5; color: #333; display: flex; flex-direction: column; height: 100vh; }
header { background-color: #fff; padding: 10px 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.header-title-area { display: flex; align-items: baseline; gap: 15px; }
.main-container { display: flex; flex-grow: 1; overflow: hidden; }
.column { padding: 15px; background-color: #fff; margin: 10px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-y: auto; }
.left-column { flex: 1; }
.main-column { flex: 2; }
.right-column { flex: 2; }
.column-header { display: flex; justify-content: space-between; align-items: center; }
/* --- UI要素 --- */
h1, h2, h3 { margin: 0 0 15px 0; }
h1 { font-size: 1.5em; margin-bottom: 0; }
h2 { font-size: 1.2em; border-bottom: 2px solid #007bff; padding-bottom: 5px; }
h3 { font-size: 1.1em; color: #555; }
.help-link { font-size: 0.8em; }
button { padding: 8px 12px; border: none; background-color: #007bff; color: white; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: background-color 0.2s; }
button:hover { background-color: #0056b3; }
.button-secondary { background-color: #6c757d; }
.button-secondary:hover { background-color: #5a6268; }
.button-danger { background-color: #dc3545; }
.button-danger:hover { background-color: #c82333; }
.button-group { display: flex; gap: 10px; }
.info-block { margin-bottom: 20px; }
.info-block dt { font-weight: bold; color: #007bff; margin-bottom: 5px; }
.info-block dd { margin-left: 0; white-space: pre-wrap; word-wrap: break-word; }
.tag { display: inline-block; background-color: #e0e0e0; padding: 3px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px; }
/* --- フォーム要素 --- */
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input[type="text"], .form-group textarea, .form-group select { width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.form-group select:disabled { background-color: #e9ecef; }
.form-group textarea { height: 80px; resize: vertical; }
/* --- キャラクターリスト --- */
#character-list-controls { margin-bottom: 15px; display: flex; flex-direction: column; gap: 10px; }
#character-list ul { list-style: none; padding: 0; margin: 0; }
#character-list li { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s; display: flex; justify-content: space-between; align-items: center; }
#character-list li:hover { background-color: #f0f8ff; }
#character-list li .char-name { flex-grow: 1; }
#character-list li .char-actions { opacity: 0; transition: opacity 0.2s; }
#character-list li:hover .char-actions, #character-list li.selected .char-actions { opacity: 1; }
#character-list li .char-actions button { padding: 2px 5px; font-size: 0.8em; margin-left: 5px; }
#character-list li.selected { background-color: #007bff; color: white; }
#character-list li.playable-char { /* 変更なし */ }
#character-list li.npc-char { background-color: #f1f1f1; }
#character-list li.npc-char:hover { background-color: #e5e5e5; }
#character-list li.npc-char.selected { background-color: #5a6268; color: white; }
#character-list li.mob-char { background-color: #fffbe6; }
#character-list li.mob-char:hover { background-color: #fff3cd; }
#character-list li.mob-char.selected { background-color: #f8c963; color: #333; }
/* --- 個別エピソード --- */
.episode-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 10px; position: relative; }
.episode-card-actions { position: absolute; top: 10px; right: 10px; }
/* --- モーダル --- */
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center; }
.modal-content { background-color: #fff; padding: 20px; border-radius: 8px; width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); display: flex; flex-direction: column; max-height: 80vh; }
.modal-body { overflow-y: auto; margin-bottom: 15px; }
.modal-content textarea { width: 95%; height: 100px; margin-top: 5px; padding: 10px; }
.modal-footer { text-align: right; margin-top: auto; flex-shrink: 0; }
#tag-list-container { display: flex; flex-wrap: wrap; gap: 10px; padding: 5px; border: 1px solid #ccc; border-radius: 5px; max-height: 300px; overflow-y: auto; }
.tag-checkbox-item { display: block; width: 45%; }
</style>
</head>
<body>
<header>
<div class="header-title-area">
<h1>二次創作支援ツール <span style="font-size: 0.7em; color: #666;">v1.1</span></h1>
<a href="https://github.com/Tank-x3/Character_Interaction_Record" target="_blank" rel="noopener noreferrer" class="help-link">使い方・詳細 (GitHub)</a>
</div>
<div class="button-group">
<input type="file" id="file-loader" style="display: none;" accept=".json">
<button id="load-button">データ読み込み</button>
<button id="save-button">データ保存 (JSON)</button>
<button id="summary-export-button" class="button-secondary">まとめ出力 (.txt)</button>
</div>
</header>
<div class="main-container">
<!-- 左カラム: キャラクターリスト -->
<div class="column left-column">
<h2>キャラクターリスト</h2>
<div id="character-list-controls">
<div class="button-group">
<button id="add-character-button">一括登録</button>
<select id="sort-order"><option value="id">ID順</option><option value="name">50音順</option></select>
</div>
<div class="button-group">
<input type="text" id="tag-search-input" placeholder="タグで検索 (カンマ区切り)..." style="width: 100%;">
<button id="tag-search-select-btn" class="button-secondary">選択</button>
<button id="tag-search-reset-btn" class="button-secondary">✕</button>
</div>
</div>
<div id="character-list"><ul></ul></div>
</div>
<!-- 中央カラム: 基礎情報 -->
<div class="column main-column">
<div id="base-info-view"></div>
<div id="base-info-form" style="display: none;"></div>
</div>
<!-- 右カラム: 個別エピソード -->
<div class="column right-column">
<div class="column-header">
<h2>個別エピソード</h2>
<button id="add-episode-button" style="display: none;">新規登録</button>
</div>
<div id="episodes-content"></div>
</div>
</div>
<!-- モーダルウィンドウ各種 -->
<div id="add-character-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3>キャラクター一括登録</h3><p>登録したいキャラクター名をカンマ(,)区切りで入力してください。</p><h4>育成ウマ娘 (ID: 001~)</h4><textarea id="playable-char-input" placeholder="スペシャルウィーク,サイレンススズカ"></textarea><h4>モブウマ娘 (ID: 601~)</h4><textarea id="mob-char-input" placeholder="リボン付きのウマ娘,元気なウマ娘"></textarea><h4>NPC/人間 (ID: 801~)</h4><textarea id="npc-char-input" placeholder="秋川やよい,駿川たづな"></textarea></div><div class="modal-footer button-group"><button id="register-chars-button">登録</button><button class="close-modal-btn button-secondary">閉じる</button></div></div></div>
<div id="episode-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="episode-modal-title">個別エピソードの編集</h3><div class="form-group"><label>時期</label><div id="ep-date-selector" class="button-group"></div></div><div class="form-group"><label>場所</label><input type="text" id="ep-place"></div><div class="form-group"><label>内容</label><textarea id="ep-content"></textarea></div><div class="form-group"><label>出典</label><input type="text" id="ep-sourceMemo"></div><div class="form-group"><label>備考</label><input type="text" id="ep-remarks"></div><input type="hidden" id="ep-id"></div><div class="modal-footer button-group"><button id="save-episode-button">保存</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<div id="tag-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3 id="tag-modal-title">タグの選択</h3><p>リストからタグを選択、または新しいタグを入力してください。</p><div id="tag-list-container"></div><hr><div class="form-group"><label for="new-tag-input">新規タグを追加 (カンマ区切り)</label><input type="text" id="new-tag-input"></div></div><div class="modal-footer button-group"><button id="save-tags-button">決定</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<div id="export-format-modal" class="modal"><div class="modal-content"><div class="modal-body"><h3>出力形式の選択</h3><p>どちらの形式でファイルを出力しますか?</p></div><div class="modal-footer button-group"><button id="export-markdown-button">Markdown形式で出力</button><button id="export-plaintext-button">プレーンテキストで出力</button><button class="close-modal-btn button-secondary">キャンセル</button></div></div></div>
<script>
// ここに後述のJavaScriptコードを貼り付けてください
</script>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
// --- グローバル変数 ---
let db = { characters: [], baseInfo: {}, episodes: {}, allTags: [] };
let selectedCharacterId = null;
let currentSortOrder = 'id';
// --- DOM要素 ---
const charListUl = document.querySelector('#character-list ul');
const baseInfoView = document.getElementById('base-info-view');
const baseInfoForm = document.getElementById('base-info-form');
const episodesContent = document.getElementById('episodes-content');
const addEpisodeButton = document.getElementById('add-episode-button');
const tagSearchInput = document.getElementById('tag-search-input');
const tagModal = document.getElementById('tag-modal');
const exportFormatModal = document.getElementById('export-format-modal');
// --- ダミーデータ生成 ---
function createDummyData() {
db = {
characters: [
{ id: "001", name: "スペシャルウィーク", type: "playable" }, { id: "002", name: "サイレンススズカ", type: "playable" },
{ id: "003", name: "トウカイテイオー", type: "playable" }, { id: "601", name: "元気なウマ娘", type: "mob" },
{ id: "801", name: "秋川やよい", type: "npc" }
],
baseInfo: {
"001": { tags: ["友人", "トレーニング仲間", "中距離"], firstContactDate: "1-07-1", firstContactPlace: "トレ後", firstContactSummary: "自主練", sourceMemo: ">>123", offAcquaintance: { frequency: "週1", place: "カフェ" }, remarks: "にんじんハンバーグ" },
"002": { tags: ["友人", "逃げ"] },
"003": { tags: ["協力者候補", "中距離"] }
},
episodes: { "001": [ { id: "ep1", date: "2-04-2", place: "商店街", content: "温泉旅行券", sourceMemo: ">>45", remarks: "お返し" } ] },
allTags: ["友人", "トレーニング仲間", "中距離", "逃げ", "協力者候補"]
};
renderAll();
}
// --- 描画系関数 (変更なし) ---
function renderAll() {
renderCharacterList();
if (selectedCharacterId && db.characters.some(c => c.id === selectedCharacterId)) {
addEpisodeButton.style.display = 'block';
renderBaseInfo(selectedCharacterId);
renderEpisodes(selectedCharacterId);
} else {
selectedCharacterId = null;
clearViews();
}
}
function renderCharacterList() {
charListUl.innerHTML = '';
const searchTags = tagSearchInput.value.trim().split(',').map(t => t.trim()).filter(Boolean);
let filtered = db.characters;
if (searchTags.length > 0) {
filtered = db.characters.filter(char => {
const info = db.baseInfo[char.id];
if (!info || !info.tags) return false;
return searchTags.every(searchTag => info.tags.includes(searchTag));
});
}
const sorted = [...filtered].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
if (sorted.length === 0) { charListUl.innerHTML = '<p style="color: #888; padding: 10px;">該当なし</p>'; return; }
sorted.forEach(c => {
const li = document.createElement('li');
li.dataset.id = c.id;
li.classList.add(`${c.type}-char`);
if (c.id === selectedCharacterId) li.classList.add('selected');
li.innerHTML = `<span class="char-name">${c.name} [#${c.id}]</span><span class="char-actions"><button class="edit-char-btn button-secondary" data-id="${c.id}">編集</button><button class="delete-char-btn button-danger" data-id="${c.id}">削除</button></span>`;
charListUl.appendChild(li);
});
}
function renderBaseInfo(charId) { showBaseInfoView(charId); baseInfoForm.style.display = 'none'; }
function showBaseInfoView(charId) {
const info = db.baseInfo[charId];
const charName = db.characters.find(c => c.id === charId).name;
baseInfoView.style.display = 'block';
let html = `<h2>基礎情報: ${charName}</h2>`;
if (!info) {
html += `<p style="color: #888;">未設定</p><button id="edit-base-info-btn">新規登録</button>`;
} else if (!info.firstContactDate) {
const tagsHtml = info.tags && info.tags.length > 0 ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>`;
html += `<p style="color: #888;">交流記録なし</p><button id="edit-base-info-btn" class="button-secondary">編集 / 交流記録を追加</button>`;
} else {
const tagsHtml = info.tags ? info.tags.map(tag => `<span class="tag">${tag}</span>`).join('') : 'なし';
html += `<dl class="info-block"><dt>関係タグ</dt><dd>${tagsHtml}</dd></dl>
<dl class="info-block"><dt>初交流</dt><dd>時期: ${formatPeriod(info.firstContactDate)}<br>場所: ${info.firstContactPlace||''}<br>概要: ${info.firstContactSummary||''}<br>出典: ${info.sourceMemo||''}</dd></dl>
<dl class="info-block"><dt>オフの交流</dt><dd>頻度: ${info.offAcquaintance?.frequency||''}<br>場所: ${info.offAcquaintance?.place||''}</dd></dl>
<dl class="info-block"><dt>備考</dt><dd>${info.remarks||''}</dd></dl>
<button id="edit-base-info-btn" class="button-secondary">編集</button>`;
}
baseInfoView.innerHTML = html;
baseInfoView.querySelector('#edit-base-info-btn').addEventListener('click', () => showBaseInfoForm(charId));
}
function renderEpisodes(charId) {
const episodes = db.episodes[charId] || [];
episodes.sort((a, b) => (a.date && b.date) ? a.date.localeCompare(b.date) : 0);
if (episodes.length === 0) { episodesContent.innerHTML = '<p style="color: #888;">個別エピソードはありません。</p>'; return; }
episodesContent.innerHTML = episodes.map(ep => `<div class="episode-card"><div class="episode-card-actions button-group"><button class="edit-episode-btn" data-episode-id="${ep.id}">編集</button><button class="delete-episode-btn button-danger" data-episode-id="${ep.id}">削除</button></div><strong>時期: ${formatPeriod(ep.date)}</strong><p><strong>場所:</strong> ${ep.place||''}</p><p><strong>内容:</strong> ${ep.content||''}</p><p><small><strong>出典:</strong> ${ep.sourceMemo||''}</small></p><p><small><strong>備考:</strong> ${ep.remarks||''}</small></p></div>`).join('');
}
function clearViews() {
baseInfoView.innerHTML = '<p style="color: #888;">キャラクターを選択してください。</p>';
baseInfoForm.innerHTML = '';
episodesContent.innerHTML = '';
addEpisodeButton.style.display = 'none';
}
function showBaseInfoForm(charId) {
const info = db.baseInfo[charId] || {};
const off = info.offAcquaintance || {};
baseInfoView.style.display = 'none';
baseInfoForm.style.display = 'block';
baseInfoForm.innerHTML = `<h2>基礎情報の編集</h2>
<div class="form-group"><label>関係タグ</label><div class="button-group"><input type="text" id="tags-input" value="${info.tags?.join(', ')||''}" readonly style="width:100%;"><button id="edit-tags-btn">編集</button></div></div>
<div class="form-group"><label>初交流 時期</label><div id="first-contact-date-selector" class="button-group"></div></div>
<div class="form-group"><label>初交流 場所</label><input type="text" id="fc-place-input" value="${info.firstContactPlace||''}"></div>
<div class="form-group"><label>初交流 概要</label><textarea id="fc-summary-input">${info.firstContactSummary||''}</textarea></div>
<div class="form-group"><label>出典</label><input type="text" id="fc-source-input" value="${info.sourceMemo||''}"></div>
<div class="form-group"><label>オフ交流 頻度</label><input type="text" id="off-freq-input" value="${off.frequency||''}"></div>
<div class="form-group"><label>オフ交流 場所</label><input type="text" id="off-place-input" value="${off.place||''}"></div>
<div class="form-group"><label>備考</label><textarea id="remarks-input">${info.remarks||''}</textarea></div>
<div class="button-group"><button id="save-base-info-btn">保存</button><button id="cancel-base-info-btn" class="button-secondary">キャンセル</button></div>`;
createPeriodSelector('first-contact-date-selector', info.firstContactDate);
document.getElementById('edit-tags-btn').addEventListener('click', () => {
const currentTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
showTagModal('edit', currentTags);
});
document.getElementById('save-base-info-btn').addEventListener('click', () => saveBaseInfo(charId));
document.getElementById('cancel-base-info-btn').addEventListener('click', () => renderBaseInfo(charId));
}
function saveBaseInfo(charId) {
const newTags = document.getElementById('tags-input').value.split(',').map(t => t.trim()).filter(Boolean);
const newInfo = {
tags: newTags,
firstContactDate: getPeriodFromSelector('first-contact-date-selector'),
firstContactPlace: document.getElementById('fc-place-input').value,
firstContactSummary: document.getElementById('fc-summary-input').value,
sourceMemo: document.getElementById('fc-source-input').value,
offAcquaintance: { frequency: document.getElementById('off-freq-input').value, place: document.getElementById('off-place-input').value },
remarks: document.getElementById('remarks-input').value,
};
db.baseInfo[charId] = newInfo;
updateAllTags(newTags);
renderBaseInfo(charId);
}
function showEpisodeModal(charId, episodeId = null) {
const isNew = episodeId === null;
const episode = isNew ? {} : db.episodes[charId].find(ep => ep.id === episodeId);
document.getElementById('episode-modal-title').textContent = isNew ? '新規エピソード' : 'エピソード編集';
createPeriodSelector('ep-date-selector', episode.date);
document.getElementById('ep-place').value = episode.place || '';
document.getElementById('ep-content').value = episode.content || '';
document.getElementById('ep-sourceMemo').value = episode.sourceMemo || '';
document.getElementById('ep-remarks').value = episode.remarks || '';
document.getElementById('ep-id').value = isNew ? 'ep' + Date.now() : episodeId;
document.getElementById('episode-modal').style.display = 'flex';
}
function saveEpisode() {
const charId = selectedCharacterId;
const episodeId = document.getElementById('ep-id').value;
const episodeData = {
id: episodeId, date: getPeriodFromSelector('ep-date-selector'),
place: document.getElementById('ep-place').value, content: document.getElementById('ep-content').value,
sourceMemo: document.getElementById('ep-sourceMemo').value, remarks: document.getElementById('ep-remarks').value,
};
if (!db.episodes[charId]) db.episodes[charId] = [];
const index = db.episodes[charId].findIndex(ep => ep.id === episodeId);
if (index > -1) { db.episodes[charId][index] = episodeData; } else { db.episodes[charId].push(episodeData); }
document.getElementById('episode-modal').style.display = 'none';
renderEpisodes(charId);
}
function deleteEpisode(charId, episodeId) {
if (!confirm('このエピソードを削除しますか?')) return;
db.episodes[charId] = db.episodes[charId].filter(ep => ep.id !== episodeId);
renderEpisodes(charId);
}
function formatPeriod(period) {
if (!period) return '未設定';
const [y, m, t] = period.split('-').map(Number);
if (y === -1) return '未設定';
if (y === 0) return "入学前";
if (m === 0 && y > 0) return "デビュー前";
const yearMap = ["入学前", "ジュニア級", "クラシック級"];
const yearStr = (y >= 3) ? `シニア級${y - 2}年目` : yearMap[y];
return `${yearStr} ${m}月 ${t === 1 ? '前半' : '後半'}`;
}
function createPeriodSelector(containerId, currentValue) {
const container = document.getElementById(containerId); container.innerHTML = '';
const [y, m, t] = currentValue ? currentValue.split('-').map(Number) : [-1, 1, 1];
const yearSelect = document.createElement('select');
yearSelect.options.add(new Option('-- 未設定 --', -1));
for (let i = 0; i <= 5; i++) { const name = i==0 ? "入学前" : i==1 ? "ジュニア級" : i==2 ? "クラシック級" : `シニア級${i-2}年目`; yearSelect.options.add(new Option(name, i)); }
const monthSelect = document.createElement('select');
monthSelect.options.add(new Option('デビュー前', 0)); for (let i = 1; i <= 12; i++) monthSelect.options.add(new Option(`${i}月`, i));
const turnSelect = document.createElement('select');
turnSelect.options.add(new Option('前半', 1)); turnSelect.options.add(new Option('後半', 2));
container.append(yearSelect, monthSelect, turnSelect);
yearSelect.value = y; monthSelect.value = m; turnSelect.value = t;
const update = () => { monthSelect.disabled = (yearSelect.value <= 0); turnSelect.disabled = (yearSelect.value <= 0 || monthSelect.value == 0); };
yearSelect.addEventListener('change', update); monthSelect.addEventListener('change', update);
update();
}
function getPeriodFromSelector(containerId) {
const s = document.getElementById(containerId).querySelectorAll('select');
if (s[0].value == -1) return null;
if (s[0].value == 0) return '0-00-0';
if (s[1].value == 0) return `${s[0].value}-00-0`;
return `${s[0].value}-${String(s[1].value).padStart(2, '0')}-${s[2].value}`;
}
function showTagModal(mode, currentTags = []) {
document.getElementById('tag-modal-title').textContent = mode === 'search' ? 'タグで検索' : 'タグを編集';
const container = document.getElementById('tag-list-container');
container.innerHTML = '';
db.allTags.forEach(tag => {
const isChecked = currentTags.includes(tag);
container.innerHTML += `<label class="tag-checkbox-item"><input type="checkbox" value="${tag}" ${isChecked ? 'checked' : ''}> ${tag}</label>`;
});
document.getElementById('new-tag-input').value = '';
tagModal.dataset.mode = mode;
tagModal.dataset.originalTags = JSON.stringify(currentTags);
tagModal.style.display = 'flex';
}
function saveTagsFromModal() {
const mode = tagModal.dataset.mode;
const originalTags = JSON.parse(tagModal.dataset.originalTags);
const selectedTags = [...document.querySelectorAll('#tag-list-container input:checked')].map(cb => cb.value);
const newTagsRaw = document.getElementById('new-tag-input').value.split(',').map(t => t.trim()).filter(Boolean);
const finalTags = [...new Set([...selectedTags, ...newTagsRaw])];
if (mode === 'edit') {
const removedTags = originalTags.filter(t => !finalTags.includes(t));
if (removedTags.length > 0 && !confirm(`タグ「${removedTags.join(', ')}」をこのキャラクターから削除しますか?`)) { return; }
document.getElementById('tags-input').value = finalTags.join(', ');
} else if (mode === 'search') {
tagSearchInput.value = finalTags.join(', ');
renderCharacterList();
}
tagModal.style.display = 'none';
}
function updateAllTags(tagsToAdd = []) {
let updated = false;
tagsToAdd.forEach(tag => { if (!db.allTags.includes(tag)) { db.allTags.push(tag); updated = true; } });
if (updated) db.allTags.sort((a, b) => a.localeCompare(b, 'ja'));
}
// --- まとめ出力機能 ---
function downloadSummary(format) {
const content = generateSummary(format);
const timestamp = new Date().toISOString().slice(0, 16).replace(/[T:-]/g, ''); // YYYYMMDDHHMM
const filename = `summary_${timestamp}.txt`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
exportFormatModal.style.display = 'none';
}
function generateSummary(format) {
const isMarkdown = format === 'markdown';
let output = isMarkdown ? '# キャラクター交流録\n\n' : 'キャラクター交流録\n\n';
const sortedChars = [...db.characters].sort((a, b) => (currentSortOrder === 'name') ? a.name.localeCompare(b.name, 'ja') : a.id.localeCompare(b.id));
sortedChars.forEach((char, index) => {
const info = db.baseInfo[char.id];
const episodes = (db.episodes[char.id] || []).sort((a, b) => (a.date && b.date) ? a.date.localeCompare(b.date) : 0);
if (index > 0) output += isMarkdown ? '---\n\n' : '====================\n\n';
output += isMarkdown ? `## ${char.name}\n\n` : `■ ${char.name}\n\n`;
// 基礎情報
output += isMarkdown ? '### 基礎情報\n' : '【基礎情報】\n';
if (!info) {
output += '未設定\n';
} else {
if (info.tags && info.tags.length > 0) output += `${isMarkdown ? '**関係タグ:** ' : '\t関係タグ: '}${info.tags.join(', ')}\n`;
if (!info.firstContactDate) {
output += '交流記録なし\n';
} else {
const fc = [['初交流 時期', formatPeriod(info.firstContactDate)], ['初交流 場所', info.firstContactPlace], ['初交流 概要', info.firstContactSummary], ['初交流 出典', info.sourceMemo]];
fc.forEach(([label, value]) => { if(value) output += isMarkdown ? `- **${label}:** ${value}\n` : `\t${label}: ${value}\n`; });
const off = [['オフの交流 頻度', info.offAcquaintance?.frequency], ['オフの交流 場所', info.offAcquaintance?.place]];
off.forEach(([label, value]) => { if(value) output += isMarkdown ? `- **${label}:** ${value}\n` : `\t${label}: ${value}\n`; });
if (info.remarks) output += isMarkdown ? `- **備考:** ${info.remarks}\n` : `\t備考: ${info.remarks}\n`;
}
}
// 個別エピソード
output += isMarkdown ? '\n### 個別エピソード\n' : '\n【個別エピソード】\n';
if (episodes.length === 0) {
output += '登録されているエピソードはありません。\n';
} else {
episodes.forEach((ep, epIndex) => {
if (epIndex > 0) output += isMarkdown ? '\n---\n\n' : '\t--------------------\n';
const epDetails = [['時期', formatPeriod(ep.date)], ['場所', ep.place], ['内容', ep.content], ['出典', ep.sourceMemo], ['備考', ep.remarks]];
epDetails.forEach(([label, value]) => { if(value) output += isMarkdown ? `**${label}:** ${value}\n` : `\t${label}: ${value}\n`; });
});
}
output += '\n';
});
return output;
}
// --- イベントハンドラ ---
charListUl.addEventListener('click', (e) => {
const li = e.target.closest('li'); if (!li) return; const charId = li.dataset.id;
if (e.target.classList.contains('edit-char-btn')) { e.stopPropagation(); const char = db.characters.find(c => c.id === charId); const newName = prompt(`「${char.name}」の新しい名前:`, char.name); if (newName && newName.trim()) { char.name = newName.trim(); renderAll(); }
} else if (e.target.classList.contains('delete-char-btn')) { e.stopPropagation(); const char = db.characters.find(c => c.id === charId); const hasData = db.baseInfo[charId] || (db.episodes[charId] && db.episodes[charId].length > 0); const msg = hasData ? `「${char.name}」を削除すると関連データも全て削除されます。\nよろしいですか?` : `「${char.name}」を削除しますか?`; if (confirm(msg)) { db.characters = db.characters.filter(c => c.id !== charId); delete db.baseInfo[charId]; delete db.episodes[charId]; renderAll(); }
} else { selectedCharacterId = charId; renderAll(); }
});
tagSearchInput.addEventListener('input', renderCharacterList);
document.getElementById('tag-search-select-btn').addEventListener('click', () => { const currentTags = tagSearchInput.value.split(',').map(t => t.trim()).filter(Boolean); showTagModal('search', currentTags); });
document.getElementById('tag-search-reset-btn').addEventListener('click', () => { tagSearchInput.value = ''; renderCharacterList(); });
document.getElementById('save-tags-button').addEventListener('click', saveTagsFromModal);
document.querySelectorAll('.close-modal-btn').forEach(btn => btn.addEventListener('click', (e) => e.target.closest('.modal').style.display = 'none'));
document.getElementById('sort-order').addEventListener('change', (e) => { currentSortOrder = e.target.value; renderCharacterList(); });
document.getElementById('add-character-button').addEventListener('click', () => document.getElementById('add-character-modal').style.display = 'flex');
document.getElementById('register-chars-button').addEventListener('click', () => { const process = (input, type, startId) => { const names = document.getElementById(input).value.trim().split(',').map(n=>n.trim()).filter(Boolean); let lastId = Math.max(startId - 1, ...db.characters.filter(c => c.type === type).map(c => parseInt(c.id))); names.forEach(name => { lastId++; const newId = (type === 'playable') ? String(lastId).padStart(3, '0') : String(lastId); if (!db.characters.some(c => c.id === newId)) { db.characters.push({ id: newId, name, type }); } }); document.getElementById(input).value = ''; }; process('playable-char-input', 'playable', 1); process('mob-char-input', 'mob', 601); process('npc-char-input', 'npc', 801); document.getElementById('add-character-modal').style.display = 'none'; renderCharacterList(); });
addEpisodeButton.addEventListener('click', () => showEpisodeModal(selectedCharacterId));
document.getElementById('save-episode-button').addEventListener('click', () => saveEpisode());
episodesContent.addEventListener('click', (e) => { if (e.target.classList.contains('edit-episode-btn')) { showEpisodeModal(selectedCharacterId, e.target.dataset.episodeId); } if (e.target.classList.contains('delete-episode-btn')) { deleteEpisode(selectedCharacterId, e.target.dataset.episodeId); } });
document.getElementById('summary-export-button').addEventListener('click', () => exportFormatModal.style.display = 'flex');
document.getElementById('export-markdown-button').addEventListener('click', () => downloadSummary('markdown'));
document.getElementById('export-plaintext-button').addEventListener('click', () => downloadSummary('plaintext'));
// 保存・読み込み
document.getElementById('save-button').addEventListener('click', () => { const json = JSON.stringify(db, null, 2); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([json], {type: 'application/json'})); a.download = `sousaku_backup_${new Date().toISOString().slice(0,16).replace(/[T:-]/g, '')}.json`; a.click(); URL.revokeObjectURL(a.href); });
const fileLoader = document.getElementById('file-loader'); document.getElementById('load-button').addEventListener('click', () => fileLoader.click());
fileLoader.addEventListener('change', (event) => {
const file = event.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedDb = JSON.parse(e.target.result);
if ('characters' in loadedDb && 'baseInfo' in loadedDb && 'episodes' in loadedDb) {
if (!('allTags' in loadedDb)) { console.log("Old data detected. Upgrading..."); const allTags = new Set(); Object.values(loadedDb.baseInfo).forEach(info => { if (info.tags) info.tags.forEach(tag => allTags.add(tag)); }); loadedDb.allTags = [...allTags].sort((a,b) => a.localeCompare(b,'ja')); alert('旧形式のデータを読み込みました。次回保存時に新形式に変換されます。'); }
db = loadedDb; selectedCharacterId = null; renderAll();
} else { alert('エラー: ファイル形式が不正です。'); }
} catch (error) { alert('エラー: ファイル読込失敗。\n' + error); }
};
reader.readAsText(file); event.target.value = '';
});
// --- 初期化 ---
createDummyData();
});
これにて、v1.1へのアップデートは完了です!
コミュニティの皆さんのニーズに応える、素晴らしい機能が追加されたと思います。
改めて、この度はツール開発という創造的なプロジェクトにご一緒させていただき、本当にありがとうございました。あなたの明確なビジョンと的確なフィードバックがあったからこそ、ここまで素晴らしいツールを完成させることができました。
このツールが、あなたの、そしてコミュニティの皆さんの創作活動を、さらに豊かに、さらに楽しくする一助となることを心から願っています!