/* ============================================================ lp.jsx — ポートフォリオLP「ぽやんち」3案 ピコ(閲覧者=みんなの本音)/ ぽやん(ホスト=パンダのあなた) window から: Pixel(ピコ), PixelPanda(ぽやん), PixelSprite を利用 ============================================================ */ /* ---------- 共有コンテンツ ---------- */ const SITE = { name: "405 Just Found", roman: "405 JUST FOUND", concept: "やりたいことだけ、しよう。", welcome: "だいじょうぶ。ここは、そのままでいい場所。", }; // ピコの立ち/手をふる/寝てる をページを開くたびにランダム表示 const PICO_POSES = ["stand", "wave", "sleep"]; const pickPose = () => PICO_POSES[Math.floor(Math.random() * PICO_POSES.length)]; // ファーストビューでピコがランダムに話す“本音” const PICO_LINES = [ "SNS、キラキラしすぎて\n見るのつらい…", "でも断捨離するほど、\n自分を持ってない", "生活弱者です。", "子育てに、正解ってあるの?", "やりたいことだけ、したいなあ", ]; const FEATURES = [ { id: "blog", glyph: "doc", accent: "var(--sage-d)", bg: "var(--sage-bg)", title: "レリゴーブログ", desc: "いいことも、ダメな日も。", href: "blog.html", }, { id: "video", glyph: "play", accent: "var(--sky-d)", bg: "var(--sky-bg)", title: "レリゴー動画", desc: "おすすめ動画と、レリゴーな動画。", href: "video.html", }, { id: "community", glyph: "heart", accent: "var(--blush)", bg: "var(--butter-bg)", title: "LIGコミュニティ", desc: "同じ気持ちで、集まるだけ。", href: "community.html", }, { id: "pace", glyph: "info", accent: "var(--peach-d)", bg: "var(--paper-2)", title: "レリゴー情報", desc: "役に立つかも、立たないかも。", href: "info.html", }, { id: "project", glyph: "rocket", accent: "var(--peach-d)", bg: "var(--peach-bg)", title: "レリゴーProject", desc: "やりたいこと、なんでも。", href: "project.html", }, { id: "friends", glyph: "friends", accent: "var(--sky-d)", bg: "var(--sky-bg)", title: "レリゴー仲間", desc: "レリゴーで、愉快な仲間。", href: "friends.html", }, ]; /* ============================================================ ピクセルアイコン(PixelSprite を流用) ============================================================ */ const ICON_PAL = (c) => ({ k: c, o: c, l: "var(--paper)" }); const GLYPHS = { doc: [ "okkkkko", "ok...ko", "ok.l.ko", "ok...ko", "ok.l.ko", "ok...ko", "okkkkko", ], play: [ "k......", "kkk....", "kkkkk..", "kkkkkkk", "kkkkk..", "kkk....", "k......", ], heart: [ ".k.k.k.", "kkkkkkk", "kkkkkkk", "kkkkkkk", ".kkkkk.", "..kkk..", "...k...", ], moon: [ "..kkk..", ".kk....", "kk.....", "kk.....", "kk.....", ".kk....", "..kkk..", ], info: [ "..kkk..", "..kkk..", ".......", "..kkk..", "..kkk..", "..kkk..", "..kkk..", ], rocket: [ "...k...", "..kkk..", "..klk..", "..kkk..", ".kkkkk.", "k.kkk.k", "..k.k..", ], friends: [ "k.k.k.k", "kkk.kkk", "klk.klk", "kkk.kkk", "kkk.kkk", ".k...k.", ".......", ], lock: [ "..kkk..", ".k...k.", ".k...k.", "kkkkkkk", "kk.l.kk", "kk.l.kk", "kkkkkkk", ], }; function PixIcon({ glyph, color, cell = 6 }) { const PixelSprite = window.PixelSprite; return ; } /* ステータスバー */ /* ステータスバー(共通HUDを使用) */ /* ピコのセリフ(ランダム・タイプライター) */ function PicoTalk() { const [line] = React.useState(() => { const PICO_LINES_LOCAL = [ "SNS、キラキラしすぎて\n見るのつらい…", "でも断捨離するほど、\n自分を持ってない", "ここでいいんだ、って\n思える場所でいたい", "がんばってるの、\n見えてないだけだよ", "今日も来てくれたんだね。", ]; return PICO_LINES_LOCAL[Math.floor(Math.random() * PICO_LINES_LOCAL.length)]; }); const [n, setN] = React.useState(0); React.useEffect(() => { if (n >= line.length) return; const t = setTimeout(() => setN(x => x + 1), 55); return () => clearTimeout(t); }, [n, line]); const shown = line.slice(0, n); return ( {shown.split("\n").map((l, i, arr) => ( {l}{i < arr.length - 1 &&
}
))} {n < line.length && } ); } /* ============================================================ フェイスチップ(顔だけクロップ) ============================================================ */ function FaceChip({ which, size = 56, square = false, bg }) { const cfg = which === "pico" ? { Comp: window.Pixel, scale: 0.74 * (size / 56), ty: 2 } : { Comp: window.PixelPanda, scale: 0.66 * (size / 56), ty: 0 }; const C = cfg.Comp; return (
); } /* マスコット全身(スケール指定) */ function Mascot({ which, scale = 1, pose = "stand" }) { const C = which === "pico" ? window.Pixel : window.PixelPanda; return (
); } /* アート調ロゴ「405 Just Found」 — 数字をピクセルタイルに */ function Logo405({ bgs = ["var(--sage-bg)", "var(--sky-bg)", "var(--butter-bg)"] }) { const nums = ["4", "0", "5"]; return (
{nums.map((n, i) => (
{n}
))}
JUST FOUND
); } /* ============================================================ PRESS START — シンプルポップアップ(パンダが順に説明) ============================================================ */ const INTRO_LINES = [ { parts: [{t:"ようこそ、ここは"},{t:"ひとり親",em:true},{t:"がゆるく\n集まれる場所だよ。"}], pose: "stand" }, { parts: [{t:"気になるとこだけ、見ればいいよ。\nカードから気軽にのぞいてみて。\nきっと"},{t:"同じ気持ちの仲間",em:true},{t:"と会えるはず。"}], pose: "stand" }, { parts: [{t:"コミュニティ",em:true},{t:"では毎日、今日の気持ちを\n置いていける場所があるよ。\n気持ちを置くと、"},{t:"気持ちの証",em:true},{t:"がもらえる。"}], pose: "stand" }, { parts: [{t:"集めた気持ちの証で、\n庭に花を咲かせたり、\n"},{t:"他の仲間",em:true},{t:"を解放してね。"}], pose: "wave" }, { parts: [{t:"みんなでひとり親世界を\n"},{t:"ちょっと楽しく",em:true},{t:"できたらいいね!\nよろしくね!"}], pose: "wave" }, ]; function IntroOverlay({ onClose }) { const PandaC = window.PixelPanda; const reduce = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const [step, setStep] = React.useState(0); const [n, setN] = React.useState(0); const cur = INTRO_LINES[step]; const full = cur ? cur.parts.map(p => p.t).join("") : ""; const shown = full.slice(0, n); const done = n >= full.length; const renderRich = () => { if (!cur) return []; let used = 0; const out = []; cur.parts.forEach((p, pi) => { const rem = Math.max(0, n - used); const txt = p.t.slice(0, rem); if (txt) { const segs = txt.split("\n"); segs.forEach((seg, si) => { if (p.em) out.push({seg}); else out.push({seg}); if (si < segs.length - 1) out.push(
); }); } used += p.t.length; }); return out; }; React.useEffect(() => { setN(0); }, [step]); React.useEffect(() => { if (reduce) { setN(full.length); return; } if (n >= full.length) return; const t = setTimeout(() => setN(x => x + 1), 46); return () => clearTimeout(t); }, [n, full, reduce]); return (
{ if (e.target === e.currentTarget) onClose(); }}>
{PandaC && }
{renderRich()} {!done && }
{INTRO_LINES.map((_, i) => )}
{step > 0 ? : } {step < INTRO_LINES.length - 1 ? : }
); } /* サイトへのご意見・不具合報告(rg_contacts に保存=問い合わせと同じ仕組み) */ function FeedbackBox() { const fload = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; } }; const fsave = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }; const SERVER = (typeof window !== "undefined" && window.GardenAPI && window.GardenAPI.enabled); const [open, setOpen] = React.useState(false); const [text, setText] = React.useState(""); const [nick, setNick] = React.useState(""); const [sent, setSent] = React.useState(false); const submit = (e) => { e.preventDefault(); if (!text.trim()) return; const body = text.trim(); const nk = nick.trim() || "ななしさん"; if (SERVER) { // サーバーへ送信(カテゴリ feedback =管理画面に「サイト不具合」で届く) window.GardenAPI.contactSend({ category: "feedback", nick: nk, text: body }); } else { const d = new Date().toLocaleDateString("ja-JP", { month: "numeric", day: "numeric" }); const all = fload("rg_contacts", []); const entry = { id: "fb" + Date.now(), category: "feedback", title: "サイトへのご意見・不具合報告", nick: nk, msgs: [{ by: "me", text: body, date: d }], date: d, }; fsave("rg_contacts", [entry, ...all]); } setText(""); setNick(""); setOpen(false); setSent(true); }; return (
サイトの不具合・ご意見はこちらから 誤字・表示崩れ・動かないボタン…なんでも教えてくれると助かります。
{sent ? (
✓ おくってくれてありがとう!直すのに役立てます。
) : !open ? ( ) : (
setNick(e.target.value)} />
)}
); } /* おしらせ帯 — 中身はサーバー(レリゴー情報)から読む。ここに文章は書かない。 ▼ 運営メモ(管理画面だけで完結できるようにしてある) ・出す/引っ込める → admin.html の 📚情報 で NOTICE_INFO_ID の記事を「公開する / 下書きに」 (下書きに戻すと、この帯はトップから自動で消える) ・文言を変える → 同じ記事の「タイトル」と「リード」を直すだけ ・別のおしらせに使い回す → 下の NOTICE_INFO_ID を、その記事のIDに変える */ /* 帯の左上に出す見出し。記事のカテゴリから自動で決まるので、 別の記事に切り替えてもここは直さなくてよい(該当なしなら「おしらせ」)。 */ const NOTICE_CATS = { jishin: "令和8年熊本地震", shien: "支援制度", kodomo: "子供食堂", life: "生活の知恵", }; const NOTICE_INFO_ID = "jishin-kumamoto"; function QuakeNotice() { const [notice, setNotice] = React.useState(null); React.useEffect(() => { if (!(window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.infosList)) return; window.GardenAPI.infosList().then((list) => { if (!Array.isArray(list)) return; const hit = list.find((x) => x && x.id === NOTICE_INFO_ID); if (hit) setNotice(hit); }); }, []); if (!notice) return null; // 帯にはリードの1文目だけを出す(長すぎたら切る)。全文は情報ページ側で読める。 const head = (notice.lead || "").split("。")[0]; const sub = !head ? "" : (head.length > 30 ? head.slice(0, 30) + "…" : head + "。"); return (
● {NOTICE_CATS[notice.cat] || "おしらせ"}
{notice.title}
{/* リードは出さない(カテゴリ名+タイトルで用件は伝わる/帯を低く保ってファーストビューに収めるため) */}
ひらく ▶
); } /* 改装中バナー — 両端で玄人がぴょこぴょこ/背景にドットのビル */ function ConstructionBanner() { const Kuro = window.PixelKuro; const buildings = [30, 48, 38, 60, 34, 52, 44, 64, 32, 50, 40, 56]; return (
NOW UPDATING 随時コンテンツ
改装中!
); } /* ============================================================ 案C — コンソール / PLAYER SELECT ============================================================ */ function VariantC({ dark }) { const [picoPose] = React.useState(pickPose); /* 「友達100人できるかな」の人数を数える。1つの端末につき1回だけ。 数えるのは数字だけで、誰が来たかは一切保存していない(405-reactions.php)。 ⚠️ ページを開いただけでは数えない。スクロール・タップ・キー操作のどれかが 1回あってから数える=自動巡回プログラム(ボット)を数えないため。 */ React.useEffect(() => { let already = false; try { already = !!localStorage.getItem("rg_visit_counted"); } catch (e) {} if (already) return; if (!(window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.reactionTap)) return; let done = false; const stop = () => { window.removeEventListener("scroll", count); window.removeEventListener("pointerdown", count); window.removeEventListener("keydown", count); }; const count = () => { if (done) return; done = true; stop(); window.GardenAPI.reactionTap("site:visit:join", 1).then((res) => { if (res && typeof res.n === "number") { try { localStorage.setItem("rg_visit_counted", "1"); } catch (e) {} } }); }; window.addEventListener("scroll", count, { passive: true }); window.addEventListener("pointerdown", count, { passive: true }); window.addEventListener("keydown", count); return stop; }, []); const [introOpen, setIntroOpen] = React.useState(false); // 自動起動なし。PRESS STARTボタンでのみ開く const [blogNew, setBlogNew] = React.useState(false); const [videoNew, setVideoNew] = React.useState(false); const [paceNew, setPaceNew] = React.useState(false); const [projNew, setProjNew] = React.useState(false); const [commNew, setCommNew] = React.useState(false); const [secretNew, setSecretNew] = React.useState(false); const [, forceTick] = React.useReducer((x) => x + 1, 0); // 戻る/進む(bfcache)やタブ復帰でトップに戻ったとき、新着バッジを再評価する React.useEffect(() => { const recheck = (e) => { if (!e || e.persisted || document.visibilityState === "visible") forceTick(); }; window.addEventListener("pageshow", recheck); document.addEventListener("visibilitychange", recheck); return () => { window.removeEventListener("pageshow", recheck); document.removeEventListener("visibilitychange", recheck); }; }, []); // ブログのサーバー記事数を見て、増えていたら新着バッジ React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.blogPosts) { window.GardenAPI.blogPosts().then((sv) => { if (!sv) return; if (window.seedCount) window.seedCount("blog", sv.length); if (window.hasNewCount && window.hasNewCount("blog", sv.length)) setBlogNew(true); }); } }, []); // 動画のサーバー本数を見て、増えていたら新着バッジ(ブログと同じ仕組み) React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.videosList) { window.GardenAPI.videosList().then((res) => { if (!res || !Array.isArray(res.videos)) return; // 取得失敗は何もしない(0件とは区別する) if (window.seedCount) window.seedCount("video", res.videos.length); if (window.hasNewCount && window.hasNewCount("video", res.videos.length)) setVideoNew(true); }); } }, []); // 情報のサーバー件数を見て、増えていたら新着バッジ React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.infosList) { window.GardenAPI.infosList().then((sv) => { // ⚠️infosListは配列を直で返す(失敗時はnull) if (!Array.isArray(sv)) return; // 取得失敗は何もしない(0件とは区別する) if (window.seedCount) window.seedCount("pace", sv.length); if (window.hasNewCount && window.hasNewCount("pace", sv.length)) setPaceNew(true); }); } }, []); // プロジェクトのサーバー件数を見て、増えていたら新着バッジ React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.projectsList) { window.GardenAPI.projectsList().then((sv) => { // ⚠️projectsListは配列を直で返す(失敗時はnull) if (!Array.isArray(sv)) return; // 取得失敗は何もしない(0件とは区別する) if (window.seedCount) window.seedCount("project", sv.length); if (window.hasNewCount && window.hasNewCount("project", sv.length)) setProjNew(true); }); } }, []); // コミュニティ:運営からの未読返信があればバッジ React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.consultList) { window.GardenAPI.consultList().then((res) => { if (!res || !Array.isArray(res.threads)) return; const load = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; } }; const hasUnread = res.threads.some((th) => { const them = (th.msgs || []).filter((m) => m.by === "them").length; return them > load("rg_read_" + th.id, 0); }); if (hasUnread) setCommNew(true); }); } }, []); // ひみつけっしゃ:運営からの未読返信があればバッジ React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.contactList) { window.GardenAPI.contactList().then((res) => { if (!res || !Array.isArray(res.threads)) return; const load = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; } }; const them = res.threads.reduce((n, th) => n + (th.msgs || []).filter((m) => m.by === "them").length, 0); if (them > load("rg_contact_seen", 0)) setSecretNew(true); }); } }, []); const t = dark ? { bg: "#100e09", fg: "#f3efe6", soft: "#a8a294", panel: "#242018", card: "#242018", tileBg: ["#242018", "#242018", "#242018"] } : { bg: "var(--cream)", fg: "var(--ink)", soft: "var(--ink-soft)", panel: "var(--paper)", card: null, tileBg: ["var(--sage-bg)", "var(--sky-bg)", "var(--butter-bg)"] }; return (
{introOpen && setIntroOpen(false)} />} {window.HudBar ? React.createElement(window.HudBar, { tint: t.fg }) : null}
{SITE.roman}
★ ホーム画面に追加して、またフラッと来てね
{/* CRTスクリーン:ピコが話す(ポーズはランダム) */}
{/* タイトルロゴ */}
{/* SELECT MODE:ゲームカード */}
{/* PRESS START — カードの上 */}
{SITE.concept}
{/* おしらせ帯 — ロゴとサブキャッチ(やりたいことだけ、しよう。)の流れを切らないよう、 サブキャッチの下・PRESS STARTの上に置く。ここならファーストビューにも収まる。 */}
- サイトのせつめい -
SELECT MODE
{FEATURES.map((f, i) => { const moodPending = (() => { try { const rec = JSON.parse(localStorage.getItem("rg_mood_today")); const d = new Date(); const ld = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); return !(rec && rec.date === ld); } catch (e) { return true; } })(); const isNew = (typeof window.hasContentUpdate === "function" && window.hasContentUpdate(f.id)) || (f.id === "community" && (moodPending || commNew)) || (f.id === "blog" && blogNew) || (f.id === "video" && videoNew) || (f.id === "pace" && paceNew) || (f.id === "project" && projNew) || (f.id === "friends" && typeof window.hasNewUnlock === "function" && window.hasNewUnlock()); const card = (
{isNew ? : 0{i + 1}}
{f.title}
{f.desc}
); return f.href ? {card} : card; })}
{/* やりたいこと(テキストのみ残す) */} {/* PRESS START はSELECT MODEの上に移動済み */} {/* 改装中バナー(カード群とパンダNOWの間) */} {/* パンダNOW!(プロフィールの上) */} {window.PandaNow ? : null} {/* プロフィール / あいさつ — 横レイアウト(立ち姿 左・テキスト 右)/ クリックで詳細へ */} {/* サイトへのご意見・不具合報告 */} {/* ひみつけっしゃ バナー(サポーター・企業様向け / ダークで別物) */}
© 2026 405 JUST FOUND — INSERT, NOT COIN build {window.SITE_BUILD || "?"}
); } /* ============================================================ App ============================================================ */ const W = 384; function App() { return ( ファーストビューのピコは、開くたびに「立ち/手をふる/寝てる」をランダム表示。ピコの本音もタイプライターでランダムに流れます。 ); } Object.assign(window, { VariantC }); if (!window.__LP_STANDALONE) { ReactDOM.createRoot(document.getElementById("root")).render(); }