Twitter風サイト

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Twitter風サイト(高度拡張版)</title>
  <style>
    /* 全体設定 */
    body {
      margin: 0;
      padding: 0;
      font-family: sans-serif;
      background-color: #f5f8fa;
    }

    header {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 50px;
      background-color: #1da1f2;
      display: flex;
      align-items: center;
      padding: 0 20px;
      color: #fff;
      font-size: 20px;
      font-weight: bold;
      box-sizing: border-box;
      z-index: 10;
    }

    /* レイアウト用コンテナ */
    .container {
      display: flex;
      width: 100%;
      max-width: 1200px;
      margin: 60px auto 0; /* ヘッダー分だけ下に余白をとる */
      box-sizing: border-box;
    }

    /* 左サイドバー(ナビゲーション) */
    .sidebar {
      width: 20%;
      max-width: 200px;
      padding: 10px;
      box-sizing: border-box;
    }

    .nav-item {
      margin: 10px 0;
      font-size: 18px;
    }

    .nav-item a {
      text-decoration: none;
      color: #1da1f2;
      cursor: pointer;
    }

    .profile-settings {
      margin-top: 20px;
      padding: 10px;
      background-color: #fff;
      border: 1px solid #e6ecf0;
      border-radius: 5px;
    }

    .profile-settings input {
      width: 100%;
      margin-bottom: 5px;
      font-size: 14px;
      padding: 5px;
      box-sizing: border-box;
    }

    .profile-settings button {
      border: none;
      background-color: #1da1f2;
      color: #fff;
      font-size: 14px;
      padding: 5px 10px;
      border-radius: 4px;
      cursor: pointer;
    }

    /* メインタイムライン部分 */
    .feed {
      width: 60%;
      padding: 10px;
      box-sizing: border-box;
    }

    .tweet-box {
      background-color: #fff;
      border: 1px solid #e6ecf0;
      border-radius: 5px;
      padding: 10px;
      margin-bottom: 20px;
    }

    .tweet-box textarea {
      width: 100%;
      border: none;
      resize: none;
      font-size: 16px;
      outline: none;
      box-sizing: border-box;
    }

    .tweet-stats {
      display: flex;
      justify-content: space-between;
      margin-top: 5px;
      font-size: 14px;
    }

    .tweet-stats .char-count {
      color: #657786;
    }

    .tweet-stats .error {
      color: red;
    }

    .tweet-box .attach-label {
      display: inline-block;
      margin-top: 5px;
      font-size: 14px;
      color: #657786;
    }

    .tweet-box button {
      margin-top: 10px;
      padding: 8px 16px;
      border: none;
      background-color: #1da1f2;
      color: #fff;
      font-size: 16px;
      border-radius: 5px;
      cursor: pointer;
    }

    .tweet {
      background-color: #fff;
      border: 1px solid #e6ecf0;
      border-radius: 5px;
      padding: 10px;
      margin-bottom: 10px;
    }

    .tweet-header {
      display: flex;
      align-items: center;
      margin-bottom: 5px;
      flex-wrap: wrap;
    }

    .tweet-header img.user-icon {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      margin-right: 10px;
    }

    .tweet-header .name {
      font-weight: bold;
      margin-right: 5px;
    }

    .tweet-header .username {
      color: #657786;
      font-size: 14px;
      margin-right: 5px;
    }

    .tweet-time {
      font-size: 12px;
      color: #657786;
    }

    .tweet-content {
      font-size: 16px;
      margin: 10px 0;
      white-space: pre-wrap; /* 改行を保持 */
    }

    .tweet-content img.attached-image {
      max-width: 100%;
      display: block;
      margin-top: 5px;
      border: 1px solid #ccc;
    }

    .tweet-footer {
      display: flex;
      justify-content: flex-start;
      gap: 15px;
      margin-top: 10px;
      flex-wrap: wrap;
    }

    .tweet-footer button {
      background: none;
      border: none;
      cursor: pointer;
      font-size: 14px;
      color: #657786;
      display: flex;
      align-items: center;
      gap: 5px;
    }

    .tweet .replies-container {
      margin-top: 10px;
      border-left: 2px solid #e6ecf0;
      padding-left: 10px;
    }

    /* リプライの更なる階層は少しずつ左にずらす */
    .nested-reply {
      margin-left: 20px;
    }

    /* 右サイドバー(ウィジェット) */
    .widgets {
      width: 20%;
      max-width: 250px;
      padding: 10px;
      box-sizing: border-box;
    }

    .search-box {
      background-color: #fff;
      border-radius: 20px;
      padding: 8px 15px;
      margin-bottom: 20px;
      border: 1px solid #e6ecf0;
      display: flex;
      align-items: center;
    }

    .search-box input {
      border: none;
      outline: none;
      width: 100%;
      font-size: 16px;
    }

    .trends {
      background-color: #fff;
      border: 1px solid #e6ecf0;
      border-radius: 5px;
      padding: 10px;
    }

    .trends h3 {
      margin-top: 0;
    }

    .trend-item {
      margin-bottom: 10px;
      font-size: 14px;
    }

    /* リンク風のテキストデザイン */
    .hashtag,
    .mention {
      color: #1da1f2;
      text-decoration: none;
      cursor: pointer;
    }

    .hashtag:hover,
    .mention:hover {
      text-decoration: underline;
    }

    /* 折りたたみ表示のボタン */
    .toggle-replies-btn {
      background: none;
      color: #1da1f2;
      border: none;
      cursor: pointer;
      font-size: 14px;
      margin-top: 5px;
      padding: 0;
    }
  </style>
</head>
<body>
  <!-- ヘッダー -->
  <header>
    Twitter風サイト(高度拡張版)
  </header>

  <!-- メインコンテンツを左右に分けるコンテナ -->
  <div class="container">

    <!-- 左サイドバー -->
    <aside class="sidebar">
      <div class="nav-item"><a href="#">ホーム</a></div>
      <div class="nav-item"><a href="#">通知</a></div>
      <div class="nav-item"><a href="#">設定</a></div>

      <!-- 簡易プロフィール設定 -->
      <div class="profile-settings">
        <label for="displayName">名前</label>
        <input type="text" id="displayName" placeholder="あなたの表示名">
        <label for="userName">ユーザー名(@なしで)</label>
        <input type="text" id="userName" placeholder="myAccount">
        <button id="saveProfileBtn">保存</button>
      </div>
    </aside>

    <!-- タイムライン部分 -->
    <main class="feed">
      <!-- 新規ツイート入力フォーム -->
      <div class="tweet-box">
        <textarea rows="3" placeholder="いまどうしてる? (140文字まで)"></textarea>
        <div class="tweet-stats">
          <span class="char-count">0 / 140</span>
          <span class="error"></span>
        </div>
        <label class="attach-label">
          画像を添付:
          <input type="file" class="attach-input" accept="image/*">
        </label>
        <button class="tweet-submit-btn">ツイート</button>
      </div>
    </main>

    <!-- 右サイドバー -->
    <aside class="widgets">
      <!-- 検索ボックス -->
      <div class="search-box">
        <input type="text" placeholder="キーワード検索">
      </div>

      <!-- トレンド表示 -->
      <div class="trends">
        <h3>今どうしてる?</h3>
        <div class="trend-item">#春の訪れ</div>
        <div class="trend-item">#お花見</div>
        <div class="trend-item">#新年度</div>
      </div>
    </aside>
  </div>

  <script>
    // =======================
    //      定数・変数設定
    // =======================
    const TWEET_MAX_LENGTH = 140;
    const feedContainer = document.querySelector('.feed');
    const tweetTextarea = document.querySelector('.tweet-box textarea');
    const tweetButton = document.querySelector('.tweet-submit-btn');
    const charCountEl = document.querySelector('.char-count');
    const errorEl = document.querySelector('.error');
    const attachInput = document.querySelector('.attach-input');

    const displayNameInput = document.getElementById('displayName');
    const userNameInput = document.getElementById('userName');
    const saveProfileBtn = document.getElementById('saveProfileBtn');

    // LocalStorageからツイート一覧を読み込み(なければ空配列)
    let tweets = JSON.parse(localStorage.getItem('tweets-advanced') || '[]');

    // ユーザープロフィール情報をLocalStorageから読み込み
    let userProfile = JSON.parse(localStorage.getItem('userProfile-advanced') || '{}');
    let currentName = userProfile.displayName || 'あなた';
    let currentUserName = userProfile.userName || 'myAccount';

    // フォームに反映
    displayNameInput.value = currentName;
    userNameInput.value = currentUserName;

    // =======================
    //   画像ファイル取得用
    // =======================
    let attachedImageBase64 = null;
    attachInput.addEventListener('change', (e) => {
      const file = e.target.files[0];
      if(!file) {
        attachedImageBase64 = null;
        return;
      }
      const reader = new FileReader();
      reader.onload = () => {
        attachedImageBase64 = reader.result;  // base64データ
      };
      reader.readAsDataURL(file);
    });

    // =======================
    //   スレッド実装のためのツイート構造
    // =======================
    // tweet = {
    //   id: string (一意のID),
    //   name: string,
    //   userName: string,
    //   content: string,
    //   time: number (Date.now()),
    //   likes: number,
    //   retweets: number,
    //   image: string (base64) | null,
    //   replies: array of same structure
    // }

    // =======================
    //       ユーティリティ
    // =======================
    function escapeHtml(str) {
      return str
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
    }

    // ハッシュタグ/メンションのリンク化
    function linkify(text) {
      let escaped = escapeHtml(text);
      escaped = escaped.replace(/#(\w+)/g, `<a href="#" class="hashtag">#$1</a>`);
      escaped = escaped.replace(/@(\w+)/g, `<a href="#" class="mention">@$1</a>`);
      return escaped;
    }

    // ツイートをLocalStorageに保存
    function updateLocalStorage() {
      localStorage.setItem('tweets-advanced', JSON.stringify(tweets));
    }

    // 親ツイートまたはリプライ先を検索するための再帰関数
    function findTweetById(tweetArray, tweetId) {
      for (const tw of tweetArray) {
        if (tw.id === tweetId) {
          return tw;
        }
        const childFound = findTweetById(tw.replies, tweetId);
        if (childFound) {
          return childFound;
        }
      }
      return null;
    }

    // 新しいツイートを作成 & tweets配列に登録
    // parentIdが指定されたら、そのツイートのrepliesに追加する
    function createNewTweet(content, parentId = null, imageBase64 = null) {
      const newTweet = {
        id: 'tw-' + Date.now() + '-' + Math.floor(Math.random() * 10000),
        name: currentName,
        userName: currentUserName,
        content: content,
        time: Date.now(),
        likes: 0,
        retweets: 0,
        image: imageBase64,
        replies: []
      };
      if (parentId) {
        const parentTweet = findTweetById(tweets, parentId);
        if (parentTweet) {
          parentTweet.replies.unshift(newTweet);
        }
      } else {
        tweets.unshift(newTweet);
      }
      updateLocalStorage();
      renderTweets();
    }

    // =======================
    //      ツイート描画
    // =======================

    // ツイート1件を生成するDOM要素を返す(返信分も含め再帰的に生成)
    // depth: スレッドの深さに応じて左マージンなどを調整したいときに利用
    function createTweetElement(tweet, depth = 0) {
      const tweetDiv = document.createElement('div');
      tweetDiv.classList.add('tweet');
      if (depth >= 1) {
        // 2段目以降のリプライならclassで左にずらす
        tweetDiv.classList.add('nested-reply');
      }

      // 日付文字列
      const timeString = new Date(tweet.time).toLocaleString('ja-JP', {
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit'
      });

      // 本文のリンク化
      const contentHtml = linkify(tweet.content);

      // 画像がある場合
      const imageHtml = tweet.image
        ? `<img src="${tweet.image}" alt="Attached Image" class="attached-image" />`
        : '';

      // 自分のツイートなら削除ボタンを表示
      const isMyTweet = (tweet.name === currentName && tweet.userName === currentUserName);
      const deleteBtnHtml = isMyTweet
        ? `<button class="delete-btn">削除</button>`
        : '';

      tweetDiv.innerHTML = `
        <div class="tweet-header">
          <img src="https://via.placeholder.com/40" alt="User Icon" class="user-icon" />
          <span class="name">${escapeHtml(tweet.name)}</span>
          <span class="username">@${escapeHtml(tweet.userName)}</span>
          <span class="tweet-time">- ${timeString}</span>
        </div>
        <div class="tweet-content">
          ${contentHtml}
          ${imageHtml}
        </div>
        <div class="tweet-footer">
          <button class="like-btn">
            <span>いいね</span>
            <span class="like-count">${tweet.likes}</span>
          </button>
          <button class="retweet-btn">
            <span>リツイート</span>
            <span class="retweet-count">${tweet.retweets}</span>
          </button>
          <button class="reply-btn">返信</button>
          ${deleteBtnHtml}
        </div>
      `;

      // ---------- 返信フォーム & スレッド表示 ----------
      // 返信コンテナ(折りたたみ対象)
      const repliesContainer = document.createElement('div');
      repliesContainer.classList.add('replies-container');

      // 返信がある場合、表示/非表示を切り替えるボタンを設置
      if (tweet.replies && tweet.replies.length > 0) {
        const toggleRepliesBtn = document.createElement('button');
        toggleRepliesBtn.classList.add('toggle-replies-btn');
        toggleRepliesBtn.textContent = `返信を表示 (${tweet.replies.length})`;
        tweetDiv.appendChild(toggleRepliesBtn);

        // 折りたたみ状態管理
        let isRepliesOpen = false;
        toggleRepliesBtn.addEventListener('click', () => {
          isRepliesOpen = !isRepliesOpen;
          toggleRepliesBtn.textContent = isRepliesOpen
            ? `返信を非表示`
            : `返信を表示 (${tweet.replies.length})`;
          repliesContainer.style.display = isRepliesOpen ? 'block' : 'none';
        });
      }

      // 返信フォーム
      const replyForm = document.createElement('div');
      replyForm.style.marginTop = '5px';
      replyForm.innerHTML = `
        <textarea rows="2" placeholder="返信を入力..." style="width: 100%; font-size:14px;"></textarea>
        <button class="reply-submit-btn" style="margin-top:5px;">返信を投稿</button>
      `;
      replyForm.style.display = 'none'; // デフォルトは非表示
      tweetDiv.appendChild(replyForm);

      // スレッド(返信)の再帰描画
      tweet.replies.forEach(replyTweet => {
        const replyEl = createTweetElement(replyTweet, depth + 1);
        repliesContainer.appendChild(replyEl);
      });
      repliesContainer.style.display = 'none'; // 最初は折りたたみ
      tweetDiv.appendChild(repliesContainer);

      // ========== 各種ボタンイベント ==========
      // いいね
      const likeBtn = tweetDiv.querySelector('.like-btn');
      const likeCountEl = tweetDiv.querySelector('.like-count');
      likeBtn.addEventListener('click', () => {
        tweet.likes++;
        updateLocalStorage();
        likeCountEl.textContent = tweet.likes;
      });

      // リツイート
      const retweetBtn = tweetDiv.querySelector('.retweet-btn');
      const retweetCountEl = tweetDiv.querySelector('.retweet-count');
      retweetBtn.addEventListener('click', () => {
        tweet.retweets++;
        updateLocalStorage();
        retweetCountEl.textContent = tweet.retweets;
      });

      // 返信ボタン -> フォーム表示/非表示
      const replyBtn = tweetDiv.querySelector('.reply-btn');
      replyBtn.addEventListener('click', () => {
        replyForm.style.display = (replyForm.style.display === 'none') ? 'block' : 'none';
      });

      // 返信投稿
      const replySubmitBtn = replyForm.querySelector('.reply-submit-btn');
      const replyTextarea = replyForm.querySelector('textarea');
      replySubmitBtn.addEventListener('click', () => {
        const replyText = replyTextarea.value.trim();
        if (replyText === '' || replyText.length > TWEET_MAX_LENGTH) {
          return;
        }
        // 新規リプライ作成
        createNewTweet(replyText, tweet.id);
        replyTextarea.value = '';
      });

      // 削除
      if (isMyTweet) {
        const deleteBtn = tweetDiv.querySelector('.delete-btn');
        deleteBtn.addEventListener('click', () => {
          // 再帰的に探して削除
          removeTweetById(tweets, tweet.id);
          updateLocalStorage();
          renderTweets();
        });
      }

      return tweetDiv;
    }

    // ツイート削除(再帰)
    function removeTweetById(tweetArray, tweetId) {
      for (let i = 0; i < tweetArray.length; i++) {
        if (tweetArray[i].id === tweetId) {
          tweetArray.splice(i, 1);
          return true;
        }
        if (removeTweetById(tweetArray[i].replies, tweetId)) {
          return true;
        }
      }
      return false;
    }

    // 画面上のツイート一覧を再描画
    function renderTweets() {
      // まず既存ツイートを全削除
      const oldTweets = feedContainer.querySelectorAll('.tweet');
      oldTweets.forEach(t => t.remove());

      // 上から順にツイートを追加
      tweets.forEach(tweet => {
        const tweetEl = createTweetElement(tweet);
        feedContainer.appendChild(tweetEl);
      });
    }

    // ======================
    //   イベントリスナー
    // ======================
    window.addEventListener('DOMContentLoaded', () => {
      renderTweets();
      updateCharCount();
    });

    // ツイート文字数カウント
    tweetTextarea.addEventListener('input', updateCharCount);

    function updateCharCount() {
      const length = tweetTextarea.value.length;
      charCountEl.textContent = `${length} / ${TWEET_MAX_LENGTH}`;

      if (length > TWEET_MAX_LENGTH) {
        errorEl.textContent = '文字数オーバーです!';
        tweetButton.disabled = true;
      } else {
        errorEl.textContent = '';
        tweetButton.disabled = false;
      }
    }

    // ツイート投稿
    tweetButton.addEventListener('click', () => {
      const text = tweetTextarea.value.trim();
      if (text === '' || text.length > TWEET_MAX_LENGTH) {
        return;
      }
      createNewTweet(text, null, attachedImageBase64);
      tweetTextarea.value = '';
      attachedImageBase64 = null;
      attachInput.value = ''; // ファイル選択をクリア
      updateCharCount();
    });

    // プロフィール情報の保存
    saveProfileBtn.addEventListener('click', () => {
      currentName = displayNameInput.value.trim() || 'あなた';
      currentUserName = userNameInput.value.trim() || 'myAccount';

      userProfile = {
        displayName: currentName,
        userName: currentUserName
      };
      localStorage.setItem('userProfile-advanced', JSON.stringify(userProfile));

      alert('プロフィールを保存しました!\n' +
            `名前:${currentName}\nユーザー名:@${currentUserName}`);
      renderTweets(); // 表示名を変えて再描画
    });
  </script>
</body>
</html>

投稿者: chosuke

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

コメントを残す

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