【319号】Grok~安全Xエディタ 令和070605

 Grokのウェブアプリをひねり出してもらった


X投稿用入力画面スタイルのメモ帳をGrokに頼んだ。Javascript等を使っている。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>安全Xエディタ</title>
  <style>
    body {
      background-color: #000;
      color: #fff;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
      margin: 0;
      display: flex;
      flex-direction: column;
      height: 100vh;
      -webkit-font-smoothing: antialiased;
    }
    .toolbar {
      background-color: #16181c;
      padding: 10px;
      border-bottom: 1px solid #2f3336;
      display: flex;
      gap: 8px;
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      z-index: 1000;
      overflow-x: auto;
      white-space: nowrap;
      align-items: center;
    }
    .toolbar button {
      background-color: #2f3336;
      color: #fff;
      border: none;
      padding: 8px 14px;
      cursor: pointer;
      font-size: 14px;
      border-radius: 9999px;
      transition: background-color 0.2s;
    }
    .toolbar button:hover {
      background-color: #1d9bf0;
    }
    .toolbar label {
      color: #71767b;
      font-size: 14px;
      display: flex;
      align-items: center;
      gap: 4px;
    }
    #fileInput, #imageInput, #htmlInput {
      display: none;
    }
    #charCount {
      font-size: 14px;
      color: #71767b;
      margin-left: 10px;
    }
    #charCount.warning {
      color: #f4212e;
    }
    .editor-container {
      flex: 1;
      display: flex;
      flex-direction: column;
      margin-top: 60px;
      padding: 16px;
      overflow-y: auto;
    }
    #editor {
      flex: 1;
      background-color: transparent;
      color: #fff;
      border: none;
      padding: 12px 0;
      font-size: 48pt;
      line-height: 1.4;
      resize: none;
      outline: none;
      width: 100%;
      box-sizing: border-box;
      caret-color: #1d9bf0;
      font-family: inherit;
      overflow-y: auto;
    }
    #editor::placeholder {
      color: #71767b;
    }
    #editor:focus {
      outline: none;
    }
    .image-preview {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 12px;
    }
    .image-preview img {
      width: 120px;
      height: 120px;
      object-fit: cover;
      border-radius: 12px;
      border: 1px solid #2f3336;
    }
    .post-button {
      background-color: #1d9bf0;
      color: #fff;
      font-weight: bold;
      padding: 10px 20px;
      border-radius: 9999px;
      border: none;
      cursor: pointer;
      margin-top: 12px;
      align-self: flex-end;
    }
    .post-button:hover {
      background-color: #1a8cd8;
    }
    .post-button:disabled {
      background-color: #1d9bf066;
      cursor: not-allowed;
    }
  </style>
</head>
<body>
  <div class="toolbar" id="toolbar">
    <button onclick="insertHashtag()">#</button>
    <button onclick="toggleBold()">B</button>
    <button onclick="toggleItalic()">I</button>
    <button onclick="document.getElementById('imageInput').click()">&#128247;</button>
    <input type="file" id="imageInput" accept="image/*" multiple onchange="previewImages(event)">
    <button onclick="document.getElementById('fileInput').click()">TXT読込</button>
    <input type="file" id="fileInput" accept=".txt" onchange="loadTextFile(event)">
    <button onclick="document.getElementById('htmlInput').click()">HTML読込</button>
    <input type="file" id="htmlInput" accept=".html" onchange="loadHtmlFile(event)">
    <button onclick="saveText()">TXT保存</button>
    <button onclick="saveHtml()">HTML保存</button>
    <button onclick="clearDocument()">クリア</button>
    <button onclick="copyAllText()">コピー</button>
    <label><input type="checkbox" id="under13">13歳未満</label>
    <span id="charCount">0/280</span>
  </div>
  <div class="editor-container" id="editorContainer">
    <textarea id="editor" placeholder="いまどうしてる?" spellcheck="false"></textarea>
    <div class="image-preview" id="imagePreview"></div>
    <button class="post-button" onclick="handlePost()" disabled>ポスト</button>
  </div>

  <script>
    let imageCount = 0;
    let imageData = [];

    function insertHashtag() {
      const editor = document.getElementById('editor');
      const start = editor.selectionStart;
      editor.setRangeText('#', start, start, 'select');
      updateCharCount();
      editor.focus();
    }

    function toggleBold() {
      const editor = document.getElementById('editor');
      const start = editor.selectionStart;
      const end = editor.selectionEnd;
      const selectedText = editor.value.slice(start, end);
      if (selectedText) {
        editor.setRangeText(`**${selectedText}**`, start, end, 'select');
      } else {
        editor.setRangeText('****', start, start, 'select');
        editor.selectionStart = start + 2;
        editor.selectionEnd = start + 2;
      }
      updateCharCount();
      editor.focus();
    }

    function toggleItalic() {
      const editor = document.getElementById('editor');
      const start = editor.selectionStart;
      const end = editor.selectionEnd;
      const selectedText = editor.value.slice(start, end);
      if (selectedText) {
        editor.setRangeText(`*${selectedText}*`, start, end, 'select');
      } else {
        editor.setRangeText('**', start, start, 'select');
        editor.selectionStart = start + 1;
        editor.selectionEnd = start + 1;
      }
      updateCharCount();
      editor.focus();
    }

    function clearDocument() {
      if (confirm('内容をクリアしますか?')) {
        const editor = document.getElementById('editor');
        editor.value = '';
        document.getElementById('imagePreview').innerHTML = '';
        imageCount = 0;
        imageData = [];
        updateCharCount();
        editor.focus();
      }
    }

    function loadTextFile(event) {
      const file = event.target.files[0];
      if (file) {
        const reader = new FileReader();
        reader.onload = function(e) {
          const editor = document.getElementById('editor');
          let content = e.target.result;
          const maxLength = 280;
          if (content.length > maxLength) {
            content = content.slice(0, maxLength);
            alert('最大280文字まで読み込みました。');
          }
          editor.value = content;
          updateCharCount();
          editor.focus();
        };
        reader.readAsText(file);
      }
    }

    function loadHtmlFile(event) {
      const file = event.target.files[0];
      if (file) {
        const reader = new FileReader();
        reader.onload = function(e) {
          const editor = document.getElementById('editor');
          const preview = document.getElementById('imagePreview');
          const parser = new DOMParser();
          const doc = parser.parseFromString(e.target.result, 'text/html');
          const textDiv = doc.querySelector('#postContent');
          let content = textDiv ? textDiv.textContent : '';
          const maxLength = 280;
          if (content.length > maxLength) {
            content = content.slice(0, maxLength);
            alert('最大280文字まで読み込みました。');
          }
          editor.value = content;

          imageData = [];
          imageCount = 0;
          preview.innerHTML = '';
          const images = doc.querySelectorAll('.image-preview img');
          images.forEach(img => {
            if (imageCount < 4 && img.src.startsWith('data:image/')) {
              const newImg = document.createElement('img');
              newImg.src = img.src;
              preview.appendChild(newImg);
              imageData.push(img.src);
              imageCount++;
            }
          });

          updateCharCount();
          editor.focus();
        };
        reader.readAsText(file);
      }
    }

    function saveText() {
      const content = document.getElementById('editor').value;
      const fileName = prompt('保存するファイル名を入力(例: post.txt)', 'post.txt');
      if (fileName) {
        const blob = new Blob([content], { type: 'text/plain' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName;
        a.click();
        URL.revokeObjectURL(url);
      }
    }

    function saveHtml() {
      const content = document.getElementById('editor').value;
      const username = prompt('ユーザー名を入力してください(例: 太郎)', 'ユーザー名');
      if (!username) {
        alert('ユーザー名を入力してください。');
        return;
      }
      const handle = username.toLowerCase().replace(/\s/g, '');
      const fileName = prompt('保存するファイル名を入力(例: post.html)', 'post.html');
      if (fileName) {
        const escapedContent = content
          .replace(/&/g, '&')
          .replace(/</g, '<')
          .replace(/>/g, '>')
          .replace(/\n/g, '<br>');
        const escapedUsername = username
          .replace(/&/g, '&')
          .replace(/</g, '<')
          .replace(/>/g, '>');
        const escapedHandle = handle
          .replace(/&/g, '&')
          .replace(/</g, '<')
          .replace(/>/g, '>');
        const imageHtml = imageData.map(src => `<img src="${src}" alt="Attached image">`).join('');
        const htmlContent = `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>安全X</title>
  <style>
    body {
      background-color: #000;
      color: #fff;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
      padding: 20px;
      max-width: 600px;
      margin: 0 auto;
    }
    .post-container {
      border-bottom: 1px solid #2f3336;
      padding: 15px 0;
    }
    .post-header {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }
    .avatar {
      width: 48px;
      height: 48px;
      border-radius: 50%;
      background-color: #1d9bf0; /* 青一色 */
    }
    .username {
      font-weight: bold;
      font-size: 15px;
    }
    .handle {
      color: #71767b;
      font-size: 15px;
    }
    #postContent {
      font-size: 48pt;
      line-height: 1.4;
      white-space: pre-wrap;
      margin-bottom: 12px;
    }
    .image-preview {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
      gap: 8px;
      margin-bottom: 12px;
    }
    .image-preview img {
      width: 100%;
      max-height: 300px;
      object-fit: cover;
      border-radius: 12px;
      border: 1px solid #2f3336;
    }
    .post-actions {
      display: flex;
      gap: 40px;
      color: #71767b;
      font-size: 13px;
    }
    .action-item {
      display: flex;
      align-items: center;
      gap: 8px;
    }
  </style>
</head>
<body>
  <div class="post-container">
    <div class="post-header">
      <div class="avatar"></div>
      <div>
        <span class="username">${escapedUsername}</span>
        <span class="handle">@${escapedHandle}</span>
      </div>
    </div>
    <div id="postContent">${escapedContent}</div>
    <div class="image-preview">${imageHtml}</div>
    <div class="post-actions">
      <div class="action-item">0 リツイート</div>
      <div class="action-item">0 いいね</div>
    </div>
  </div>
</body>
</html>`;
        const blob = new Blob([htmlContent], { type: 'text/html' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName;
        a.click();
        URL.revokeObjectURL(url);
      }
    }

    function copyAllText() {
      const content = document.getElementById('editor').value;
      if (!content.trim()) {
        alert('コピーするテキストがありません。');
        return;
      }
      navigator.clipboard.writeText(content).then(() => {
        alert('テキストをコピーしました。');
      }).catch(e => {
        alert('コピーに失敗しました。');
      });
      document.getElementById('editor').focus();
    }

    function previewImages(event) {
      const files = event.target.files;
      const preview = document.getElementById('imagePreview');
      const maxImages = 4;

      for (let i = 0; i < files.length && imageCount < maxImages; i++) {
        const file = files[i];
        if (file.type.startsWith('image/')) {
          const reader = new FileReader();
          reader.onload = function(e) {
            const img = document.createElement('img');
            img.src = e.target.result;
            preview.appendChild(img);
            imageData.push(e.target.result);
            imageCount++;
            updateCharCount();
          };
          reader.readAsDataURL(file);
        }
      }

      if (files.length + imageCount > maxImages) {
        alert(`最大${maxImages}枚まで添付できます。`);
      }
    }

    function handlePost() {
      const under13 = document.getElementById('under13').checked;
      const content = document.getElementById('editor').value;
      if (!content.trim()) {
        alert('投稿するテキストがありません。');
        return;
      }
      if (under13) {
        alert('13歳未満のため、投稿はHTMLファイルとして保存されます。');
        saveHtml();
        return;
      }
      const platform = prompt('投稿先を選択してください(X または Bluesky)', 'X');
      if (platform && platform.toLowerCase() === 'bluesky') {
        shareToBluesky();
      } else {
        shareToX();
      }
    }

    function shareToX() {
      const content = document.getElementById('editor').value;
      const shareText = content.slice(0, 280);
      const shareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(shareText)}`;
      window.open(shareUrl, '_blank');
    }

    function shareToBluesky() {
      const content = document.getElementById('editor').value;
      const shareText = content.slice(0, 300);
      const shareUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`;
      window.open(shareUrl, '_blank');
    }

    function updateCharCount() {
      const editor = document.getElementById('editor');
      const content = editor.value;
      const count = content.length;
      const maxLength = 280;
      const charCount = document.getElementById('charCount');
      const postButton = document.querySelector('.post-button');

      if (count > maxLength) {
        editor.value = content.slice(0, maxLength);
        alert('最大280文字までです。');
      }

      charCount.textContent = `${count}/280`;
      charCount.classList.toggle('warning', count >= maxLength);
      postButton.disabled = count === 0 && imageCount === 0;
      editor.focus();
    }

    document.getElementById('editor').addEventListener('input', updateCharCount);
    document.getElementById('editor').focus();
    updateCharCount();
  </script>
</body>
</html>

このソースをテキストエディタ、メモ帳に貼り付けて、適当なファイル名に拡張子(.html)をつけて保存する。オフラインでもブラウザが立ち上がる。

13歳未満にチェックを入れ、ポストするをクリックするとHTMLファイル保存するようにしている。子どもにオフライン状態でPCを与えてチェックしておくとXごっこ。保存したHTMLファイルは日記帳の代わりにもなる。


オフラインだから、ネットは早いかなと思ってもこれだと安全。オフラインで遊べるツールが生成できたら紹介したい。

人気の投稿