推しアイデア
・投稿が24時間で消えるという使用は、ツイ廃に刺さる! ・自分の顔や描いたイラストがそのままリアクションとして使える! ・複数人で通話可能!
・投稿が24時間で消えるという使用は、ツイ廃に刺さる! ・自分の顔や描いたイラストがそのままリアクションとして使える! ・複数人で通話可能!
・ツイ廃を量産したいと考えたから。 ・24時間で消えるなら、普段つぶやけない闇を吐き出せるのではないかと考えたから。
・PWAとプッシュ通知! CloudFlareR2で10GBまで画像保存可能! ・WebSocketを使った複数人での通話を実現! ・PWA対応で、ネイティブアプリのような使用感を実現。
構成図
チクった投稿は24時間で消えてしまう.....その前に見なきゃ!
時間計算のロジック
function getRemainingTime(createdAt: string) { const created = new Date(createdAt).getTime(); const now = Date.now(); const expires = created + 24 * 60 * 60 * 1000; // 24時間後 const diff = expires - now; if (diff <= 0) return null; // 期限切れ const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); return `${hours.toString().padStart(2, "0")}:${minutes .toString() .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; }
残り時間の表示も自動で更新
const timerRef = useRef<NodeJS.Timeout | null>(null); useEffect(() => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { setPosts((prev) => [...prev]); // 強制再レンダリング }, 1000); // 1秒ごと更新 return () => { if (timerRef.current) clearInterval(timerRef.current); }; }, []);
某SNSのようにみんなのチクった投稿が一覧で表示される!
→GiminiのAPIを用いて生成AIを実装
localstorageでチャットの履歴を保存
const userHistoryKey = user ? `${LS_KEY}_${user.id}` : LS_KEY;
Sキーで流れ星を呼ぶ
const starfieldRef = useRef<StarfieldRef>(null); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key.toLowerCase() === 's' || event.code === 'KeyS') { const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; event.preventDefault(); starfieldRef.current?.triggerShootingStars(); } }; document.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown); }; }, []);
誰とチャットするか選択
↓チャット画面
↓プロフィール編集画面
ブックマーク画面
Google Maps APIで地図を表示、コメントをSupabaseに保存
地図上に天気、気温、コメント等を投稿
ある程度ズームするとコメントを表示する機能
const show = (map.getZoom() ?? 0) >= ZOOM_FOR_COMMENT; posts .filter((p) => typeof p.lat === "number" && typeof p.lng === "number") .forEach((p) => { const text = truncate(p.comment, 24); const ov = new Ctor(new google.maps.LatLng(p.lat!, p.lng!), text); ov.setMap(map); ov.setVisible(show); overlaysRef.current.push(ov); }); } function toggleOverlayVisibility() { const zoom = mapObj.current?.getZoom(); if (zoom == null) return; const show = zoom >= ZOOM_FOR_COMMENT; overlaysRef.current.forEach((o) => o.setVisible(show)); }
地図をクリックした位置の緯度経度を送る機能
map.addListener("click", (ev: google.maps.MapMouseEvent) => { const lat = ev.latLng?.lat(); const lng = ev.latLng?.lng(); if (lat == null || lng == null) return; onMapClick({ lat, lng }); });
撮影した画像をCloudflare R2にアップロード、画像のURLをSupabaseに保存
↓リアクションを撮る
↓リアクションスタンプとして使用
Cloudflare R2へアップロードし、URLをSpabaseに保存する機能
126 const upload = async () => { 127 setMsg(null); 128 setUploading(true); 129 try { 130 const { data: u } = await supabase.auth.getUser(); 131 const userId = u.user?.id; 132 if (!userId) throw new Error("ログインが必要です(authユーザーのみ保存可)"); 139 const c = canvasRef.current!; 140 const blob: Blob = await new Promise((res, rej) => 141 c.toBlob((b) => (b ? res(b) : rej(new Error("toBlob失敗"))), "image/jpeg", 0.92) 142 ); 145 // R2へアップロード 146 const formData = new FormData(); 147 formData.append("file", blob, "reaction.jpg"); 148 formData.append("userId", userId); 149 const res = await fetch("/api/upload-reaction", { method: "POST", body: formData }); 150 const result = await res.json(); 151 if (!res.ok) throw new Error(result.error || "R2アップロード失敗"); 152 const imageUrl = result.url; 154 // SupabaseにURL保存 155 const { error } = await supabase.from("make_stamp").insert({ make_stanp_url: imageUrl }); 156 if (error) throw error; 157 setMsg("保存しました"); 158 await fetchRecent(); 159 } catch (e: any) { 160 setMsg(`保存に失敗: ${e?.message ?? e}`); 161 } finally { 162 setUploading(false); 163 } 164 };
営業中のBARを選んで
みんなで楽しくおしゃべり
WebSocketサーバーはRailwayでデプロイしました!
interface BarRoom { id: string; // BAR固有ID title: string; // BAR名 users: Map<string, BarUser>; // 参加ユーザー createdAt: number; // 作成時刻 } interface BarUser { id: string; username: string; role: 'bartender' | 'speaker' | 'listener'; // 権限レベル isMuted: boolean; // ミュート状態 ws: WebSocket; // 個別WebSocket接続 }
case 'user_joined': setUsers(prev => [...prev, data.user]); // 新ユーザー追加 case 'user_left': setUsers(prev => prev.filter(u => u.id !== data.user.id)); // ユーザー削除 case 'audio_chunk': handleAudioChunk(data); // 音声データ処理
// 高品質音声設定 const audioConfig = { sampleRate: 16000, // 16kHz サンプリング channelCount: 1, // モノラル echoCancellation: true, // エコー除去 noiseSuppression: true, // ノイズ除去 autoGainControl: true // 自動ゲイン制御 }; // MediaRecorder設定 const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus', // Opusコーデック audioBitsPerSecond: 32000 // 32kbps });
↓ネイティブアプリのようなUIで使用可能
↓スマートフォンでも可能
self.addEventListener('push', (event) => { let pushData = {}; if (event.data) { try { pushData = event.data.json(); options.body = pushData.body || options.body; options.title = pushData.title || 'Ikutio AllStars'; } catch (e) { console.log('Push data is not JSON:', e); } } event.waitUntil( self.registration.showNotification(options.title, options) ); });
圧が凄い......