star back image
people4
電飾 電飾
moon
astronaut

【完全無料】DIYバーチャルオフィス(PHPとJitsi Meetで作る)

BLOG AIWEBアプリWEBログ
読了約:91分

無料で使っていたバーチャルオフィスのサービス「MetaLife」が8月から有料となると、時間で追い出されたりするなど厳しくなりました。(◞‸◟)
無料コースはあるのですが、60日制限があり、毎回作り直す必要があります。

無料で使っているので文句も言えないです。

でも広告をちゃんと見てましたよね!?

確かに見てました!

自分で作れないでしょうか。AIさんに聞いてみました。

結論から言うと、「MetaLifeと『全く同じもの』を1から作るのは、気力的にも技術的にも無理」です。でも、「管理人さんの今の技術とAstroを使って、『自分専用の最高の疑似事務所』をタダで作る裏ルート」ならあります!

諦めるのはまだ早いです。なぜ無理なのかという現実と、それを突破する「賢い知恵」を整理しました。

❌ 「MetaLifeと全く同じもの」が無理な理由
MetaLifeのようなアプリは、ただのWebサイトではなく、超高度なWebアプリケーションです。

リアルタイム通信の壁: アバターが動いた位置を他の人の画面に一瞬で同期させる仕組み(WebSocketなど)が必要です。

通話・映像の壁: 近づいた人とだけビデオ通話をつなぐ仕組み(WebRTCなど)のサーバーが必要で、これには膨大な通信費(サーバー代)がかかります。

これを1からNext.jsやAstroで書こうとすると、来週のリニューアルの何十倍もの学習量が必要になり、気力が消し飛んでしまいます。

⭕️ Astroを使って「 astrowave 」に自分専用の事務所を作る裏ルート
「全部自作」は無理ですが、「外側の器(オフィス)はAstroで自分で作り、中身の難しい通信だけ『無料の既製品』を埋め込む」という方法なら、今ある技術で簡単に作れます!

アバターをグリグリ動かすことはできなくても、「自分のドメインのお気に入りの場所に、みんなが集まっていつでも顔を見て話せる空間」が、完全無料で、60日制限もなく一生使えます。

アバターは必要?いらないかもですね。

無料でタダだそうですニヤ(°∀° )ニヤ

相談を重ね、プロンプトを作成してもらいました。

プロンプトを作成

基本機能

現在ロリポップの共有サーバー上で動いている「astrowave.jp」の環境(PHP + HTML/CSS/JS)を使って、身内限定の「仕事の話を一切しないバーチャル溜まり場オフィス(index.php)」を作成したいです。

私が以前作成した「get_status.php(Adafruit IO連携のステータス管理)」の仕組みを応用して、まずは「常設ビデオ通話」と「シンプルな在席ステータス切り替え」ができる基本画面のコードを1ファイルの `index.php` として出力してください。

【画面の要件】
1. 画面中央に、Jitsi Meetの常設通話ルーム(ルーム名は任意で安全なもの)をiframeで埋め込む。
2. その下に「🟢在席」「🟡離席」「🔴退勤」の3つのボタンを配置する。
3. ボタンを押したら、非同期(JavaScript / fetch)でサーバー上のPHP(例: update_status.php)に状態を保存し、画面上の自分のステータス表示が切り替わるようにする(以前作ったget_status.phpの仕組みをイメージしています)。

【デザインの要望】
- リラックスできる秘密基地のような、ダークモード基調(背景は濃いグレーや紺)のシンプルでオシャレなUIにしてください。

まずはこのベースとなる「index.php」と、ステータスを保存・取得するためのシンプルなPHPの処理コードを作成してください。

ログアウト機能の追加

作成してくれた `index.php`(astrowave lounge)に「ログアウトボタン」を追加したいです。

【現在の問題】
一度ログインすると情報がブラウザに保持され、通話を切ってもログイン画面(パスワード入力画面)に戻ることができません。合言葉(PASSPHRASE)を切り替えたり、安全に退室したりできるようにしたいです。

【追加したい機能】
1. 画面のどこか(例えば右上の「在席人数」の横や、ステータスエリアの近く)に、目立たないシンプルな「ログアウト」ボタン(またはドアのアイコンなど)を配置してください。
2. このボタンをクリックすると、セッション(またはクッキーやlocalStorageなど、現在ログイン判定に使っているデータ)を完全に消去(クリア)し、ページをリロードして最初の「パスワード入力画面」に戻るように処理コードを修正してください。

つぶやき機能の追加

astrowave lounge(index.php)に、「仕事の話を一切しない今日のつぶやき(簡易掲示板)」機能を追加したいです。

【掲示板の仕様】
1. 画面の空きスペース(例えば「あなたのステータス」エリアの横や下)に、「今日のつぶやき」というスクロール可能なメッセージエリアを作成してください。
2. テキスト入力欄と「送信」ボタンを配置し、文字を入力して送信できるようにします。
3. 送信されたつぶやきは、サーバー上の同じディレクトリ内に自動生成する「messages.json」または「messages.txt」などのテキストファイルに、発言者名・投稿時間と一緒にPHPで保存してください(データベースは使わず、シンプルなファイル保存でお願いします)。
4. 投稿された内容に「http://」や「https://」から始まるURLが含まれている場合は、自動的にクリック可能なリンク( <a> タグ)に変換して表示するようにしてください。
5. 他のユーザーが書き込んだ際も、画面全体をリロードすることなく、JavaScript(fetch / ポーリング等)を使って一定時間おきに最新のつぶやきが自動で画面に反映されるようにしてください。

【デザインの要望】
- lounge全体のダークネイビーのトーンを壊さない、シンプルで洗練されたチャット風のUIにしてください。
- 入力欄や送信ボタンも、現在のステータスボタン(🟢🟡🔴)のようなオシャレなデザインで統一してください。

現在の `index.php` のコードに、この掲示板用のHTML/CSS/JS/PHP処理を追加・統合した完全なコードを出力してください。

懸念点の微調整

今日のつぶやき、
messages.jsonは際限なく肥大化しますでしょうか。
消せるようにしたいです。誰でも。

ログアウトしたら、
ステータスから、消えるようにしたいです。

Cursorにコード作成をお願いします。

DIYバーチャルオフィス(サンプルページ)
https://astrowave.jp/office/
PASS:oasis(期間限定公開中)

astrowave.jp/office/
├── index.php ← メイン画面
├── api.php ← ステータスAPI
├── members.json ← 自動生成(書き込み権限が必要)
└── messages.json ← 自動生成(書き込み権限が必要)

設置手順(3ステップ)
・自分のサーバーに office などの適当なフォルダを作ります。
・上記の index.php と api.php をアップロードします。
・index.php の中にある設定項目を自分好みに書き換えます。

/* ─── index.php 設定 ─── */
const ROOM_NAME = 'sample-lounge-private-●●●●●●'; // 他の人と被らない複雑な名前に!
const PASSPHRASE = '●●●●●●●●●●'; // 入室用の合言葉

/* ─── api.php 設定 ─── */
// index.php の PASSPHRASE と同じものを設定してください
$API_PASSPHRASE = '●●●●●●●●●●';

作成したファイルの役割

ファイル役割
index.phpメイン画面(認証 → Jitsi Meet通話 → ステータス管理)
api.phpステータスの保存・取得API(json にファイル保存)

画面の仕組み

カメラやマイクを使うので「OK」(許可)してください。

  1. 入室画面 — 名前(自由)と合言葉(●●●●●●)を入力して入室。身内限定の簡易認証です
  2. 常設ビデオ通話 — 画面中央にJitsi Meetのルーム(astrowave-lounge-private-●●●●●●)がiframeで自動接続。マイクはミュート起動
  3. ステータスボタン — 🟢在席 / 🟡離席 / 🔴退勤 を押すと、api.php 経由で members.json に非同期保存
  4. メンバー一覧 — 15秒間隔で自動更新。在席→離席→退勤の順にソート表示
  5. 自動退勤 — ブラウザを閉じると sendBeacon で自動的に退勤状態に。1時間操作がなくても自動退勤
Element社のパブリックサーバーが開通して快適に使えている今だからこそ、「これがいつまで続くのか」「終わったらどうしよう」という不安が出てくるのは当然の心理ですよね。

結論から言うと、「Element社のサービス自体はかなり長持ちする可能性が高いですが、いつかは終わるかもしれない。そして、それを『自分でやる』のは、今の管理人さんの技術力なら『思ったより難しくない』」です!

なぜずっと続くと言えるのか、そして万が一のときに自分でやる方法(難易度)について、専門用語を噛み砕いて解説します。

Element社のサーバーが簡単には終わらない理由

実は、彼らがこのサーバーを無料で一般公開しているのには、ちゃんとした「大人のビジネスの理由」があります。

  • 「看板(宣伝)」としての役割: Element社は、政府や大企業向けに有料の超強固な通信システムを売って大儲けしている会社です。このパブリックサーバーは「うちの技術はこんなに素晴らしいですよ」と世界に見せるためのショールーム(無料サンプル)なので、会社の予算が続く限り、簡単には閉じません。
  • データが残らないからエコ: ビデオ通話は、データがサーバーを「通り過ぎる」だけです。YouTubeやブログのように、サーバーに数ギガものデータを「保存し続ける」必要がないため、Element社側の電気代やハードウェアの負担は驚くほど軽いです。

そのため、明日明後日にいきなり使えなくなるリスクは極めて低いです。安心して使い倒してください。

何人まで同時に一緒に入れる?

結論から言うと、技術的には 「最大100人まで」 入ることができます。

ただし、全員がカメラをONにして快適に、遅延なくスムーズに雑談を楽しむための「現実的なおすすめ人数」としては、「30人〜35人前後」くらいまでがベストと言われています。

身内のたまり場や、ちょっとしたチームの雑談部屋、あるいは少人数のミーティングで使う分には、上限を気にする必要はまったくありません。いつでも安心して皆を招待してください!

🇩🇪 Jitsi Meetとドイツの会社(Element社)について
そうなんです!今回Jitsiの裏側で繋がっているのは、ドイツの Element(エレメント)社 が運用しているサーバーです。

実は、ヨーロッパ(特にドイツ)は個人情報の保護や通信のプライバシーに関する法律(GDPRなど)が世界で一番厳しいことで有名です。

Element社は、その厳しいヨーロッパの基準に準拠した「極めて安全でプライベートなチャット・通話ツール」を開発している会社です。そんな信頼性の高い会社が提供してくれているパブリックサーバーなので、セキュリティの面でも非常に安心感があります。

とAIさんが言ってます。大丈夫でしょうか。

【共有】ソースコード

わたしでも使えるような「シンプルなもの」にしたいです。

DIYバーチャルオフィス(サンプルページ)
https://astrowave.jp/office/
PASS:●●●●●●●●●●

astrowave.jp/office/
├── index.php ← メイン画面
├── api.php ← ステータスAPI
├── members.json ← 自動生成(書き込み権限が必要)
└── messages.json ← 自動生成(書き込み権限が必要)

設置手順(3ステップ)
・自分のサーバーに office などの適当なフォルダを作ります。
・上記の index.php と api.php をアップロードします。
・index.php の中にある設定項目を自分好みに書き換えます。

/* ─── index.php 設定 ─── */
const ROOM_NAME = 'sample-lounge-private-●●●●●●'; // 他の人と被らない複雑な名前に!
const PASSPHRASE = '●●●●●●●●●●'; // 入室用の合言葉

/* ─── api.php 設定 ─── */
// index.php の PASSPHRASE と同じものを設定してください
$API_PASSPHRASE = '●●●●●●●●●●';

index.php

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>astrowave lounge</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#0f1117;
  --surface:#1a1d27;
  --surface2:#232736;
  --border:#2e3347;
  --text:#e2e4ed;
  --text-dim:#8b8fa7;
  --accent:#6c8cff;
  --green:#34d399;
  --yellow:#fbbf24;
  --red:#f87171;
}
html{font-size:15px}
body{
  background:var(--bg);
  color:var(--text);
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Hiragino Kaku Gothic ProN','Noto Sans JP',sans-serif;
  min-height:100dvh;
  display:flex;
  flex-direction:column;
}

/* ── header ── */
.top-bar{
  display:flex;
  align-items:center;
  justify-content:space-between;
  padding:.7rem 1.2rem;
  background:var(--surface);
  border-bottom:1px solid var(--border);
}
.top-bar h1{
  font-size:1rem;
  font-weight:600;
  letter-spacing:.04em;
  display:flex;
  align-items:center;
  gap:.45rem;
}
.top-bar h1 .icon{font-size:1.15rem}
.top-bar-right{
  display:flex;
  align-items:center;
  gap:.75rem;
}
.badge-online{
  font-size:.7rem;
  background:var(--surface2);
  color:var(--text-dim);
  padding:.15rem .55rem;
  border-radius:10px;
  border:1px solid var(--border);
}
.btn-logout{
  background:transparent;
  border:none;
  color:var(--text-dim);
  cursor:pointer;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:.3rem;
  border-radius:6px;
  transition:all .2s;
}
.btn-logout:hover{
  color:var(--red);
  background:rgba(248,113,113,.1);
}

/* ── main layout ── */
.main{
  flex:1;
  display:flex;
  flex-direction:column;
  gap:1rem;
  padding:1rem;
  max-width:960px;
  width:100%;
  margin:0 auto;
}

/* ── video area ── */
.video-section{
  background:var(--surface);
  border:1px solid var(--border);
  border-radius:12px;
  overflow:hidden;
  width:100%;
  aspect-ratio:16/9;
  max-height:75vh;
  min-height:480px;
  display:flex;
  flex-direction:column;
}
.video-section .label{
  padding:.55rem .9rem;
  font-size:.75rem;
  color:var(--text-dim);
  border-bottom:1px solid var(--border);
  display:flex;
  align-items:center;
  gap:.35rem;
}
.video-section iframe{
  flex:1;
  width:100%;
  border:none;
  background:#000;
}

/* ── status panel ── */
.status-panel{
  background:var(--surface);
  border:1px solid var(--border);
  border-radius:12px;
  padding:1rem 1.2rem;
}
.status-panel .panel-title{
  font-size:.8rem;
  color:var(--text-dim);
  margin-bottom:.75rem;
  display:flex;
  align-items:center;
  gap:.35rem;
}

.status-btns{
  display:flex;
  gap:.5rem;
  flex-wrap:wrap;
}
.status-btn{
  flex:1;
  min-width:100px;
  padding:.6rem .8rem;
  border:1px solid var(--border);
  border-radius:8px;
  background:var(--surface2);
  color:var(--text);
  font-size:.85rem;
  cursor:pointer;
  transition:all .2s;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:.35rem;
}
.status-btn:hover{border-color:var(--accent);background:rgba(108,140,255,.08)}
.status-btn.active-green{border-color:var(--green);background:rgba(52,211,153,.1);color:var(--green)}
.status-btn.active-yellow{border-color:var(--yellow);background:rgba(251,191,36,.1);color:var(--yellow)}
.status-btn.active-red{border-color:var(--red);background:rgba(248,113,113,.1);color:var(--red)}

/* ── members ── */
.members-list{
  margin-top:1rem;
  display:flex;
  flex-direction:column;
  gap:.35rem;
}
.member-row{
  display:flex;
  align-items:center;
  gap:.5rem;
  padding:.4rem .6rem;
  border-radius:6px;
  font-size:.85rem;
  background:var(--surface2);
}
.member-row .dot{
  width:8px;height:8px;border-radius:50%;flex-shrink:0;
}
.dot-online{background:var(--green)}
.dot-away{background:var(--yellow)}
.dot-offline{background:var(--red)}
.member-row .name{font-weight:500}
.member-row .time{margin-left:auto;font-size:.72rem;color:var(--text-dim)}

/* ── chat panel ── */
.chat-panel{
  background:var(--surface);
  border:1px solid var(--border);
  border-radius:12px;
  display:flex;
  flex-direction:column;
  overflow:hidden;
}
.chat-panel .panel-title{
  font-size:.8rem;
  color:var(--text-dim);
  padding:.7rem 1rem;
  border-bottom:1px solid var(--border);
  display:flex;
  align-items:center;
  gap:.35rem;
  flex-shrink:0;
}
.btn-clear-chat{
  margin-left:auto;
  background:transparent;
  border:none;
  color:var(--text-dim);
  cursor:pointer;
  font-size:.75rem;
  padding:.2rem .4rem;
  border-radius:4px;
  transition:all .2s;
}
.btn-clear-chat:hover{
  background:rgba(255,255,255,.1);
  color:var(--text);
}
.chat-messages{
  flex:1;
  overflow-y:auto;
  padding:.6rem .8rem;
  display:flex;
  flex-direction:column;
  gap:.5rem;
  max-height:260px;
  min-height:120px;
  scroll-behavior:smooth;
}
.chat-messages::-webkit-scrollbar{width:4px}
.chat-messages::-webkit-scrollbar-track{background:transparent}
.chat-messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
.chat-msg{
  display:flex;
  gap:.5rem;
  align-items:flex-start;
}
.chat-avatar{
  width:28px;height:28px;
  border-radius:50%;
  background:var(--accent);
  color:#fff;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:.7rem;
  font-weight:700;
  flex-shrink:0;
  margin-top:.1rem;
}
.chat-avatar.me{background:var(--green)}
.chat-body{min-width:0;flex:1}
.chat-meta{
  display:flex;
  align-items:baseline;
  gap:.4rem;
  margin-bottom:.15rem;
}
.chat-meta .chat-name{font-size:.78rem;font-weight:600}
.chat-meta .chat-time{font-size:.65rem;color:var(--text-dim)}
.chat-text{
  font-size:.82rem;
  line-height:1.45;
  color:var(--text);
  word-break:break-word;
}
.chat-text a{color:var(--accent);text-decoration:underline}
.chat-text a:hover{opacity:.8}
.chat-empty{
  text-align:center;
  color:var(--text-dim);
  font-size:.78rem;
  padding:2rem 0;
}
.chat-input-area{
  display:flex;
  gap:.4rem;
  padding:.6rem .8rem;
  border-top:1px solid var(--border);
  flex-shrink:0;
}
.chat-input{
  flex:1;
  padding:.5rem .7rem;
  border:1px solid var(--border);
  border-radius:8px;
  background:var(--surface2);
  color:var(--text);
  font-size:.85rem;
  resize:none;
  font-family:inherit;
  line-height:1.4;
}
.chat-input::placeholder{color:var(--text-dim)}
.chat-input:focus{outline:none;border-color:var(--accent)}
.chat-send{
  padding:.5rem .8rem;
  border:1px solid var(--border);
  border-radius:8px;
  background:var(--accent);
  color:#fff;
  font-size:.8rem;
  font-weight:600;
  cursor:pointer;
  transition:all .2s;
  flex-shrink:0;
  display:flex;
  align-items:center;
  gap:.25rem;
}
.chat-send:hover{opacity:.85}
.chat-send:disabled{opacity:.4;cursor:default}

/* ── bottom row (status + chat side by side on wide screens) ── */
.bottom-panels{
  display:grid;
  grid-template-columns:minmax(300px, 400px) 1fr;
  gap:1rem;
}

/* ── footer ── */
.bottom-bar{
  text-align:center;
  padding:.6rem;
  font-size:.65rem;
  color:var(--text-dim);
  border-top:1px solid var(--border);
}
.bottom-bar a{color:var(--accent);text-decoration:none}

/* ── auth overlay ── */
.auth-overlay{
  position:fixed;inset:0;
  background:var(--bg);
  display:flex;
  align-items:center;
  justify-content:center;
  z-index:999;
}
.auth-box{
  background:var(--surface);
  border:1px solid var(--border);
  border-radius:14px;
  padding:2.5rem 2rem;
  text-align:center;
  max-width:340px;
  width:90%;
}
.auth-box h2{font-size:1.1rem;margin-bottom:.4rem}
.auth-box p{font-size:.8rem;color:var(--text-dim);margin-bottom:1.2rem}
.auth-box input{
  width:100%;
  padding:.6rem .8rem;
  border:1px solid var(--border);
  border-radius:8px;
  background:var(--surface2);
  color:var(--text);
  font-size:.9rem;
  margin-bottom:.6rem;
}
.auth-box input::placeholder{color:var(--text-dim)}
.auth-box input:focus{outline:none;border-color:var(--accent)}
.auth-box button{
  width:100%;
  padding:.6rem;
  border:none;
  border-radius:8px;
  background:var(--accent);
  color:#fff;
  font-size:.9rem;
  font-weight:600;
  cursor:pointer;
  transition:opacity .2s;
}
.auth-box button:hover{opacity:.85}
.auth-error{color:var(--red);font-size:.75rem;margin-top:.4rem;display:none}

.auth-members-preview{
  margin-top:1.5rem;
  padding-top:1rem;
  border-top:1px solid var(--border);
  font-size:.8rem;
  color:var(--text-dim);
}
.auth-members-preview .online-dot{
  display:inline-block;
  width:6px;height:6px;
  background:var(--green);
  border-radius:50%;
  margin-right:5px;
  vertical-align:middle;
}

.hidden{display:none!important}

/* ── responsive ── */
@media(max-width:700px){
  .main{padding:.6rem}
  .video-section{min-height:260px}
  .status-btns{flex-direction:column}
  .status-btn{min-width:auto}
  .bottom-panels{grid-template-columns:1fr}
}
</style>
</head>
<body>

<!-- ── 簡易パスワード認証 ── -->
<div class="auth-overlay" id="authOverlay">
  <div class="auth-box">
    <h2>🍀 astrowave lounge</h2>
    <p>仕事の話は一切禁止の溜まり場です</p>
    <input type="text" id="authName" placeholder="あなたの名前" maxlength="20" autocomplete="off">
    <input type="password" id="authPass" placeholder="合言葉を入力" autocomplete="off">
    <button type="button" id="authBtn">入室する</button>
    <div class="auth-error" id="authError">合言葉が違います</div>
    <div id="authMembersPreview" class="auth-members-preview"></div>
  </div>
</div>

<!-- ── メイン画面 ── -->
<div class="hidden" id="appMain">

  <div class="top-bar">
    <h1><span class="icon">🍀</span> astrowave lounge</h1>
    <div class="top-bar-right">
      <span class="badge-online" id="onlineCount">在席 0人</span>
      <button class="btn-logout" id="btnLogout" title="ログアウト">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
      </button>
    </div>
  </div>

  <div class="main">
    <!-- ビデオ通話 -->
    <div class="video-section">
      <div class="label">📹 常設トークルーム(Jitsi Meet)</div>
      <iframe
        id="jitsiFrame"
        allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write"
        loading="lazy">
      </iframe>
    </div>

    <!-- ステータス + チャット 横並び -->
    <div class="bottom-panels">
      <div class="status-panel">
        <div class="panel-title">🎯 あなたのステータス</div>
        <div class="status-btns">
          <button class="status-btn" data-status="online" id="btnOnline">🟢 在席</button>
          <button class="status-btn" data-status="away"   id="btnAway">🟡 離席</button>
          <button class="status-btn" data-status="offline" id="btnOffline">🔴 退勤</button>
        </div>
        <div class="members-list" id="membersList"></div>
      </div>

      <div class="chat-panel">
        <div class="panel-title">
          💬 今日のつぶやき
          <button class="btn-clear-chat" id="btnClearChat" title="履歴をクリア">🗑️ 消去</button>
        </div>
        <div class="chat-messages" id="chatMessages">
          <div class="chat-empty">まだつぶやきはありません</div>
        </div>
        <div class="chat-input-area">
          <input type="text" class="chat-input" id="chatInput" placeholder="仕事以外のことをつぶやく…" maxlength="500" autocomplete="off">
          <button class="chat-send" id="chatSend">送信</button>
        </div>
      </div>
    </div>
  </div>

  <div class="bottom-bar">
    ここは仕事の話を一切しない場所です — <a href="https://astrowave.jp/">astrowave.jp</a>
  </div>

</div>

<script>
(function(){
  'use strict';

  /* ─── 設定 ─── */
  const ROOM_NAME   = 'sample-lounge-private-●●●●●●●●●●';
  const PASSPHRASE  = '●●●●●●●●●●';           // 入室パスワード(FTPアップ後に変更推奨)
  const POLL_MS     = 15000;             // メンバー一覧の更新間隔(15秒)
  const API_URL     = 'api.php';

  /* ─── DOM ─── */
  const $overlay   = document.getElementById('authOverlay');
  const $app       = document.getElementById('appMain');
  const $authName  = document.getElementById('authName');
  const $authPass  = document.getElementById('authPass');
  const $authBtn   = document.getElementById('authBtn');
  const $authError = document.getElementById('authError');
  const $authPreview = document.getElementById('authMembersPreview');
  const $frame     = document.getElementById('jitsiFrame');
  const $members   = document.getElementById('membersList');
  const $onlineN   = document.getElementById('onlineCount');
  const $btnLogout = document.getElementById('btnLogout');
  const statusBtns = document.querySelectorAll('.status-btn');

  let myName = '';
  let pollTimer = null;

  /* ─── チャット DOM & 変数(enterLoungeより前に宣言が必要) ─── */
  const $chatMsgs     = document.getElementById('chatMessages');
  const $chatInput    = document.getElementById('chatInput');
  const $chatSend     = document.getElementById('chatSend');
  const $btnClearChat = document.getElementById('btnClearChat');
  const MSG_POLL      = 10000;
  let msgTimer        = null;
  let lastMsgCount    = -1;
  let previewTimer    = null;

  /* ─── 認証 ─── */
  function tryAuth(){
    const name = $authName.value.trim();
    const pass = $authPass.value.trim();
    if(!name){ $authName.focus(); return; }
    if(pass !== PASSPHRASE){
      $authError.style.display = 'block';
      $authPass.value = '';
      $authPass.focus();
      return;
    }
    myName = name;
    sessionStorage.setItem('lounge_name', name);
    sessionStorage.setItem('lounge_pass', pass); // ★ パスワードもセッションに保存
    enterLounge();
  }
  $authBtn.addEventListener('click', tryAuth);
  [$authName, $authPass].forEach(el => {
    el.addEventListener('keydown', e => { if(e.key === 'Enter') tryAuth(); });
  });

  const saved = sessionStorage.getItem('lounge_name');
  const savedPass = sessionStorage.getItem('lounge_pass');
  if(saved && savedPass === PASSPHRASE){
    myName = saved;
    enterLounge();
  } else {
    fetchPreview();
    previewTimer = setInterval(fetchPreview, POLL_MS);
  }

  function fetchPreview() {
    fetch(API_URL + '?action=list&t=' + Date.now())
      .then(r => r.json())
      .then(data => {
        const list = data.members || [];
        const onlineMembers = list.filter(m => m.status === 'online');
        if (onlineMembers.length > 0) {
          const names = onlineMembers.map(m => esc(m.name)).join(', ');
          $authPreview.innerHTML = '<span class="online-dot"></span>現在 ' + onlineMembers.length + '人が在席中<br><span style="font-size:0.75rem; opacity:0.8; display:inline-block; margin-top:0.3rem;">' + names + '</span>';
        } else {
          $authPreview.innerHTML = '現在、在席中のメンバーはいません';
        }
      })
      .catch(console.error);
  }

  /* ─── 入室 ─── */
  function enterLounge(){
    if (previewTimer) clearInterval(previewTimer);
    $overlay.classList.add('hidden');
    $app.classList.remove('hidden');

    $frame.src = 'https://meet.element.io/' + ROOM_NAME
      + '#userInfo.displayName="' + encodeURIComponent(myName) + '"'
      + '&config.prejoinConfig.enabled=false'
      + '&config.startWithAudioMuted=true'
      + '&config.disableDeepLinking=true';

    updateStatus('online');
    fetchMembers();
    fetchMessages();
    pollTimer = setInterval(fetchMembers, POLL_MS);
    msgTimer  = setInterval(fetchMessages, MSG_POLL);
  }

  /* ─── ステータス送信 ─── */
  function updateStatus(status){
    statusBtns.forEach(b => {
      b.classList.remove('active-green','active-yellow','active-red');
      if(b.dataset.status === status){
        const cls = status === 'online' ? 'active-green'
                  : status === 'away'   ? 'active-yellow'
                  : 'active-red';
        b.classList.add(cls);
      }
    });

    fetch(API_URL, {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({ action:'update', name: myName, status: status, passphrase: sessionStorage.getItem('lounge_pass') || PASSPHRASE })
    }).then(r => r.json()).then(() => fetchMembers()).catch(console.error);
  }

  statusBtns.forEach(b => {
    b.addEventListener('click', () => updateStatus(b.dataset.status));
  });

  /* ─── メンバー一覧取得 ─── */
  function fetchMembers(){
    fetch(API_URL + '?action=list&t=' + Date.now())
      .then(r => r.json())
      .then(data => renderMembers(data.members || []))
      .catch(console.error);
  }

  function renderMembers(list){
    let onlineCount = 0;
    $members.innerHTML = '';

    list.sort((a,b) => {
      const order = {online:0, away:1, offline:2};
      return (order[a.status]||9) - (order[b.status]||9);
    });

    list.forEach(m => {
      if(m.status === 'online') onlineCount++;
      const dotCls = m.status === 'online' ? 'dot-online'
                   : m.status === 'away'   ? 'dot-away'
                   : 'dot-offline';
      const label  = m.status === 'online' ? '在席'
                   : m.status === 'away'   ? '離席'
                   : '退勤';
      const t = m.updated_at || '';
      const row = document.createElement('div');
      row.className = 'member-row';
      row.innerHTML = '<span class="dot '+dotCls+'"></span>'
                    + '<span class="name">'+esc(m.name)+'</span>'
                    + '<span style="color:var(--text-dim);font-size:.75rem">'+esc(label)+'</span>'
                    + '<span class="time">'+esc(t)+'</span>';
      $members.appendChild(row);
    });

    $onlineN.textContent = '在席 ' + onlineCount + '人';
  }

  function esc(s){ const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }

  /* ─── チャット(つぶやき) ─── */
  function linkify(html){
    return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
  }

  function fetchMessages(){
    fetch(API_URL + '?action=messages&t=' + Date.now())
      .then(r => r.json())
      .then(data => renderMessages(data.messages || []))
      .catch(console.error);
  }

  function renderMessages(list){
    if(list.length === lastMsgCount) return;
    const wasAtBottom = $chatMsgs.scrollHeight - $chatMsgs.scrollTop - $chatMsgs.clientHeight < 40;
    lastMsgCount = list.length;

    if(list.length === 0){
      $chatMsgs.innerHTML = '<div class="chat-empty">まだつぶやきはありません</div>';
      return;
    }

    $chatMsgs.innerHTML = '';
    list.forEach(m => {
      const isMe = m.name === myName;
      const d = document.createElement('div');
      d.className = 'chat-msg';
      const initial = (m.name || '?').charAt(0).toUpperCase();
      d.innerHTML =
        '<div class="chat-avatar'+(isMe?' me':'')+'">'+esc(initial)+'</div>'
       +'<div class="chat-body">'
       +  '<div class="chat-meta">'
       +    '<span class="chat-name">'+esc(m.name)+'</span>'
       +    '<span class="chat-time">'+esc(m.time||'')+'</span>'
       +  '</div>'
       +  '<div class="chat-text">'+linkify(esc(m.text))+'</div>'
       +'</div>';
      $chatMsgs.appendChild(d);
    });

    if(wasAtBottom) $chatMsgs.scrollTop = $chatMsgs.scrollHeight;
  }

  function sendMessage(){
    const text = $chatInput.value.trim();
    if(!text) return;
    $chatSend.disabled = true;
    $chatInput.value = '';

    fetch(API_URL, {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({ action:'post_message', name: myName, text: text, passphrase: sessionStorage.getItem('lounge_pass') || PASSPHRASE })
    })
    .then(r => r.json())
    .then(() => { lastMsgCount = -1; fetchMessages(); })
    .catch(console.error)
    .finally(() => { $chatSend.disabled = false; $chatInput.focus(); });
  }

  $chatSend.addEventListener('click', sendMessage);
  $chatInput.addEventListener('keydown', e => {
    if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); sendMessage(); }
  });

  $btnClearChat.addEventListener('click', () => {
    if(!confirm('つぶやきの履歴をすべて消去しますか?')) return;
    fetch(API_URL, {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({ action:'clear_messages', passphrase: sessionStorage.getItem('lounge_pass') || PASSPHRASE })
    }).then(() => { lastMsgCount = -1; fetchMessages(); });
  });

  /* ─── ログアウト ─── */
  $btnLogout.addEventListener('click', () => {
    if(!confirm('ログアウトして退室しますか?')) return;
    
    // メンバーリストから完全に削除してセッションをクリア
    fetch(API_URL, {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({ action:'logout', name: myName, passphrase: sessionStorage.getItem('lounge_pass') || PASSPHRASE }),
      keepalive: true
    }).finally(() => {
      sessionStorage.removeItem('lounge_name');
      sessionStorage.removeItem('lounge_pass');
      location.reload();
    });
  });

  /* ─── 退出時に退勤 ─── */
  window.addEventListener('beforeunload', () => {
    if(!myName) return;
    const payload = JSON.stringify({ action:'update', name: myName, status:'offline', passphrase: sessionStorage.getItem('lounge_pass') || PASSPHRASE });
    navigator.sendBeacon(API_URL, new Blob([payload],{type:'application/json'}));
  });

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

api.php

<?php
// ★★★ astrowave lounge ─ ステータス & メッセージ API ★★★
// データは同ディレクトリの JSON ファイルに保存

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

$DATA_FILE = __DIR__ . '/members.json';
$MSG_FILE  = __DIR__ . '/messages.json';
$STALE_SEC = 3600;
$MSG_MAX   = 100;   // 保持する最大メッセージ数(古いものから自動削除)

// ★ サーバー側(PHP)でのパスワード認証設定 ★
// index.php の PASSPHRASE と同じものを設定してください
$API_PASSPHRASE = '●●●●●●●●●●';

// ── 汎用ファイル操作 ──
function loadJson($file) {
    if (!file_exists($file)) return [];
    $raw = @file_get_contents($file);
    if ($raw === false) return [];
    $data = json_decode($raw, true);
    return is_array($data) ? $data : [];
}

function saveJson($file, $data) {
    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    return @file_put_contents($file, $json, LOCK_EX) !== false;
}

// ── 古いメンバーを退勤にする ──
function expireStale(&$members, $staleSec) {
    $now = time();
    foreach ($members as &$m) {
        if ($m['status'] !== 'offline' && ($now - $m['ts']) > $staleSec) {
            $m['status'] = 'offline';
            $m['updated_at'] = date('H:i');
        }
    }
    unset($m);
}

// ── リクエスト振り分け ──
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';

// ========== GET ==========
if ($method === 'GET') {

    // ── メッセージ一覧 ──
    if ($action === 'messages') {
        $msgs = loadJson($MSG_FILE);
        echo json_encode(['messages' => $msgs]);
        exit;
    }

    // ── メンバー一覧(デフォルト) ──
    $members = loadJson($DATA_FILE);
    expireStale($members, $STALE_SEC);
    saveJson($DATA_FILE, $members);

    $output = array_map(function($m) {
        return [
            'name'       => $m['name'],
            'status'     => $m['status'],
            'updated_at' => $m['updated_at'] ?? '',
        ];
    }, $members);

    echo json_encode(['members' => array_values($output)]);
    exit;
}

// ========== POST ==========
if ($method === 'POST') {
    $input = json_decode(file_get_contents('php://input'), true);
    $postAction = $input['action'] ?? '';
    $passphrase = $input['passphrase'] ?? '';

    // ★ パスワード認証チェック(POSTリクエストはすべて保護)
    if ($passphrase !== $API_PASSPHRASE) {
        http_response_code(401);
        echo json_encode(['error' => 'Unauthorized']);
        exit;
    }

    // ── メッセージクリア ──
    if ($postAction === 'clear_messages') {
        saveJson($MSG_FILE, []);
        echo json_encode(['success' => true]);
        exit;
    }

    // ── ログアウト(メンバー削除) ──
    if ($postAction === 'logout') {
        $name = trim($input['name'] ?? '');
        $members = loadJson($DATA_FILE);
        $members = array_filter($members, function($m) use ($name) {
            return $m['name'] !== $name;
        });
        saveJson($DATA_FILE, array_values($members));
        echo json_encode(['success' => true]);
        exit;
    }

    // ── メッセージ投稿 ──
    if ($postAction === 'post_message') {
        $name = trim($input['name'] ?? '');
        $text = trim($input['text'] ?? '');

        if ($name === '' || $text === '') {
            http_response_code(400);
            echo json_encode(['error' => 'Name and text required']);
            exit;
        }

        $text = mb_substr($text, 0, 500);

        $msgs = loadJson($MSG_FILE);
        if (!is_array($msgs)) $msgs = []; // 念のため配列であることを保証

        $msgs[] = [
            'name' => $name,
            'text' => $text,
            'time' => date('H:i'),
            'ts'   => time(),
        ];

        // 古いメッセージを自動削除して肥大化を防止
        if (count($msgs) > $MSG_MAX) {
            $msgs = array_slice($msgs, -$MSG_MAX);
        }

        if (saveJson($MSG_FILE, array_values($msgs))) {
            echo json_encode(['success' => true]);
        } else {
            http_response_code(500);
            echo json_encode(['error' => 'Write failed']);
        }
        exit;
    }

    // ── ステータス更新 ──
    if ($postAction === 'update') {
        $name   = trim($input['name'] ?? '');
        $status = trim($input['status'] ?? '');

        if ($name === '' || !in_array($status, ['online','away','offline'], true)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid parameters']);
            exit;
        }

        $members = loadJson($DATA_FILE);
        if (!is_array($members)) $members = [];
        expireStale($members, $STALE_SEC);

        $found = false;
        foreach ($members as &$m) {
            if ($m['name'] === $name) {
                $m['status']     = $status;
                $m['ts']         = time();
                $m['updated_at'] = date('H:i');
                $found = true;
                break;
            }
        }
        unset($m);

        if (!$found) {
            $members[] = [
                'name'       => $name,
                'status'     => $status,
                'ts'         => time(),
                'updated_at' => date('H:i'),
            ];
        }

        if (saveJson($DATA_FILE, array_values($members))) {
            echo json_encode(['success' => true]);
        } else {
            http_response_code(500);
            echo json_encode(['error' => 'Write failed']);
        }
        exit;
    }
}

http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

「こういうサーバーを自分でやる」のは難しい?

もし数年後にElement社がサービスを終了してしまったとして、「じゃあ自分で通話サーバーを立てよう」となった場合、難易度はどれくらいでしょうか?

結論から言うと、今使っている「ロリポップの月数百円のサーバー」のままでは無理ですが、「VPS(仮想専用サーバー)」という月500〜1,000円程度のサーバーに引っ越せば、Cursorを使って数時間で自分で作れます。

難しそうに聞こえる「自前Jitsiサーバー」の構築ですが、仕組みはこうです。
  1. お名前.comやConoHaなどで、月1,000円以下の「VPS」というサーバーを借りる
  2. Cursorにこう指示する:「ConoHaのUbuntuサーバーに、Jitsi Meetをインストールするためのコマンドと手順をステップバイステップで教えてください」
  3. 言われた通りに黒い画面(ターミナル)にコマンドをコピペするだけ

実はJitsi Meetは、世界中の人が自分でサーバーを立てられるように「このボタン(コマンド)を押せば自動で全部インストール完了!」という親切なパッケージ(Dockerなど)を用意してくれています。

今回、Cursorを使ってPHPやJavaScriptを動かすところまで自力で辿り着きました。その執念とCursorとの連携力があれば、自前サーバーの構築も「指示通りにコップに水を注ぐ」くらいの感覚でクリアできます。

VPSを借りるなら、課金するのと同じ?

料金次第でしょうか。

Cursorに聞く時のプロンプト

「Ubuntuサーバーに、公式のDocker版Jitsi Meet(docker-jitsi-meet)をインストールしたいです。ポートの開放設定、.envの自動生成、起動コマンドまで、順番に実行するだけのコマンドを教えてください。」

実際に実行する4つのコマンド

1.Docker(Jitsiを動かすための土台アプリ)を入れる

sudo apt update && sudo apt install -y docker.io docker-compose

2.Jitsi Meetの公式プログラム(パッケージ)をダウンロードする

git clone https://github.com/jitsi/docker-jitsi-meet && cd docker-jitsi-meet

3.初期設定ファイルを作る(合言葉やURLの登録)

cp env.example .env
./gen-passwords.sh

4.起動する

docker-compose up -d

これだけで、裏側で勝手にサーバーに必要な全プログラムが自動で構築され、数分後に https://meet.astrowave.jp にアクセスすると、本家と全く同じ「自分専用のJitsi Meet」が起動します。

試したいですが、いま遊ぶお金がなくて。。

お給料が減給になるそうですね。

自己責任ですぞ。

【AI】イラストを描いてもらった

Flow」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

バーチャル空間に転送されました!

かわいいです。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

どこかで聞いたことがあるような。