/* ============================================================
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(); }}>
{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 ? (
) : (
)}
);
}
/* おしらせ帯 — 中身はサーバー(レリゴー情報)から読む。ここに文章は書かない。
▼ 運営メモ(管理画面だけで完結できるようにしてある)
・出す/引っ込める → 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 (
{buildings.map((h, i) => (
))}
{Kuro ? : null}
NOW UPDATING
随時コンテンツ
改装中!
{Kuro ? : null}
);
}
/* ============================================================
案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 = (
);
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();
}