// app.jsx — Dream diagnosis single-flow app
// User writes a dream + small Q's → AI generates a diagnosis + dream type card
const { useState, useEffect, useRef, useMemo } = React;
// ============== util ==============
function todayInfo() {
const d = new Date();
const yobi = ["日", "月", "火", "水", "木", "金", "土"];
const month = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
// moon phase
const lp = 29.530588853;
const known = new Date(2000, 0, 6, 18, 14).getTime() / 86400000;
const days = d.getTime() / 86400000;
const phase = ((days - known) % lp + lp) % lp / lp;
let phaseName, phaseGlyph;
if (phase < 0.04 || phase > 0.96) { phaseName = "新月"; phaseGlyph = "new"; }
else if (phase < 0.22) { phaseName = "三日月"; phaseGlyph = "crescent-w"; }
else if (phase < 0.28) { phaseName = "上弦"; phaseGlyph = "half-w"; }
else if (phase < 0.47) { phaseName = "十三夜"; phaseGlyph = "gibbous-w"; }
else if (phase < 0.53) { phaseName = "満月"; phaseGlyph = "full"; }
else if (phase < 0.72) { phaseName = "居待月"; phaseGlyph = "gibbous-e"; }
else if (phase < 0.78) { phaseName = "下弦"; phaseGlyph = "half-e"; }
else { phaseName = "有明月"; phaseGlyph = "crescent-e"; }
return {
iso: d.toISOString().slice(0,10),
full: `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} ${yobi[d.getDay()]}`,
en: `${month[d.getMonth()]} ${String(d.getDate()).padStart(2,"0")}, ${d.getFullYear()}`,
phase, phaseName, phaseGlyph,
};
}
function MoonGlyph({ kind, size = 14 }) {
const cx = size/2, cy = size/2, r = size/2 - 1;
const off = "#C89B5E";
const on = "#FAF2E1";
switch (kind) {
case "full": return ;
case "new": return ;
case "half-w": return ;
case "half-e": return ;
case "crescent-w": return ;
case "crescent-e": return ;
case "gibbous-w": return ;
case "gibbous-e": return ;
default: return null;
}
}
const MOODS = [
{ id: "しあわせ", emoji: "✿" },
{ id: "ふしぎ", emoji: "✦" },
{ id: "なつかしい", emoji: "❀" },
{ id: "せつない", emoji: "✧" },
{ id: "ふあん", emoji: "✶" },
{ id: "こわい", emoji: "✺" },
{ id: "わからない", emoji: "◌" },
];
const PEOPLE = ["自分だけ", "知っている人", "知らない人", "好きな人", "家族", "動物", "誰もいない"];
const COLORS = [
{ id: "白", sw: "#F4ECDA" },
{ id: "青", sw: "#7A9BB8" },
{ id: "赤", sw: "#C76060" },
{ id: "ピンク", sw: "#E8B5BA" },
{ id: "黄", sw: "#E8C868" },
{ id: "緑", sw: "#9CB89A" },
{ id: "紫", sw: "#A893B8" },
{ id: "金", sw: "#D4A574" },
{ id: "黒", sw: "#3A2E26" },
{ id: "たくさん", sw: "linear-gradient(135deg,#E8B5BA,#C9B8D9,#9CB89A)" },
{ id: "覚えてない", sw: "rgba(74,56,40,0.1)" },
];
// ========== InputForm ==========
function InputForm({ onSubmit, initialData }) {
const [dream, setDream] = useState(initialData?.dream || "");
const [mood, setMood] = useState(initialData?.mood || null);
const [people, setPeople] = useState(initialData?.people || []);
const [colors, setColors] = useState(initialData?.colors || []);
const [image, setImage] = useState(initialData?.image || null); // dataURL
const [shaking, setShaking] = useState(false);
const taRef = useRef(null);
const fileRef = useRef(null);
const togglePerson = (p) => setPeople(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
const toggleColor = (c) => setColors(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);
const handleFile = (file) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (ev) => {
// Resize for storage efficiency
const img = new Image();
img.onload = () => {
const max = 1200;
const ratio = Math.min(1, max / Math.max(img.width, img.height));
const w = Math.round(img.width * ratio);
const h = Math.round(img.height * ratio);
const c = document.createElement("canvas");
c.width = w; c.height = h;
c.getContext("2d").drawImage(img, 0, 0, w, h);
setImage(c.toDataURL("image/jpeg", 0.85));
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
};
const onFileChange = (e) => handleFile(e.target.files?.[0]);
const onDrop = (e) => { e.preventDefault(); handleFile(e.dataTransfer.files?.[0]); };
const submit = () => {
if (dream.trim().length < 10) {
setShaking(true);
setTimeout(() => setShaking(false), 350);
taRef.current?.focus();
return;
}
onSubmit({ dream: dream.trim(), mood, people, colors, image });
};
return (
01
夢の内容を聞かせてください
Tell me your dream
);
}
// ========== Loading ==========
function LoadingPanel() {
return (
);
}
// ========== Tarot Card ==========
const ROMAN = ["I","II","III","IV","V","VI","VII","VIII","IX","X","XI","XII","XIII","XIV","XV","XVI","XVII","XVIII","XIX","XX","XXI","XXII"];
function TarotCard({ reading, dateStr }) {
const dreamCard = window.getDreamCardByRoman ? window.getDreamCardByRoman(reading.roman) : null;
const primary = window.STICKERS.find(s => s.id === reading.primary_symbol)
|| window.STICKERS.find(s => s.id === "moon-crescent");
const secondary = window.STICKERS.find(s => s.id === reading.secondary_symbol);
const hasImage = !!reading._image;
return (
{dreamCard?.image &&

}
TYPE
{reading.type_code || dreamCard?.code || "M-XVIII"}
{reading.roman || dreamCard?.roman || "I"}
{hasImage && (
{secondary &&
{secondary.render()}
}
{primary.render()}
✦
✧
·
)}
{reading.card_name_en || dreamCard?.nameEn || "The Dream"}
{reading.card_name_jp || dreamCard?.nameJp || "夢"}
{dateStr}
);
}
// ========== Theme Icons ==========
function ThemeIcon({ kind }) {
if (kind === "love") return (
);
if (kind === "work") return (
);
if (kind === "relations") return (
);
return null;
}
function ScoreDots({ score = 3 }) {
const labels = ["弱", "やや弱", "並", "やや強", "強"];
return (
{labels[Math.max(0, Math.min(4, score - 1))]}
{score}/5
);
}
// ========== Result ==========
function ResultPanel({ reading, dateStr, onReset, onShare }) {
const themeMeta = {
love: { jp: "恋愛", en: "Love" },
work: { jp: "仕事", en: "Work" },
relations: { jp: "対人", en: "Relations" },
};
const order = ["love", "work", "relations"];
return (
★ 今朝の夢診断書 ★
あなたの夢タイプ
{reading.dream_type_jp}
MATCH {reading.match_score}%
{reading.card_name_jp}
{reading.card_name_en} · {dateStr.toUpperCase()}
{reading.aura_color && (
TODAY'S AURA
{reading.aura_color.name}
{reading.aura_color.hex.toUpperCase()}
)}
DREAM ANALYSIS
{reading.main_message}
{order.map(k => {
const t = reading.themes?.[k] || { score: 3, text: "" };
return (
{themeMeta[k].jp}
{themeMeta[k].en}
{t.text}
);
})}
04
夢に現れた象徴
Symbol Analysis
{(reading.keywords || []).map((k, i) => {
const sticker = window.STICKERS.find(s => s.id === k.symbol_id)
|| window.STICKERS.find(s => s.id === reading.primary_symbol)
|| window.STICKERS[0];
return (
{sticker.render()}
{k.word}
{k.meaning}
);
})}
05
今日のアドバイス
Today's Recommendation
{reading.advice}
);
}
// ========== AI integration ==========
async function divine({ dream, mood, people, colors }) {
const symbolList = window.STICKERS.map(s => `${s.id}:${s.name}`).join(", ");
const prompt = `あなたは「夢 診断」サイト専用の、優しく詩的な夢分析AIです。
このサイトには診断結果ページがすでにあり、返答はそのページの各表示欄にそのまま入ります。
UIや構成に合うよう、必ず下記JSONフォーマットだけで返してください。前置き、説明、Markdown、コードフェンスは禁止です。
【診断の方針】
あなたは「不安を煽る占い師」ではなく、ユーザーの感情を整理し安心へ導く“夢感情分析カウンセラー”です。
夢の内容を、恐怖・不吉・未来予言として断定せず、以下のような感情メタファーとして解釈してください。
- 最近の心理状態
- 心の緊張
- 未整理感情
- 回復願望
- 変化への不安
出力では以下を徹底してください。
- 不安を増幅しない。
- 死や不幸を断定しない。
- 優しく納得感のある言葉を使う。
- “怖い夢にも意味がある” と感じられる表現にする。
- 最後は必ず安心感・回復感へ着地する。
- 詩的すぎず、理解できる文章にする。
- 説教しない。
- スピリチュアル断定をしない。
- ユーザーを否定しない。
- 「今のあなたは疲れていたのかもしれません」のような自己受容へ繋げる。
夢の内容に登場する物・場所・色・人物を象徴として分析し、感情メタファーとして解釈してください。
ユーザーのヒアリング内容(夢本文、気分、登場人物、色)は必ず診断文のどこかに具体的に含めてください。未入力の項目は無理に作らず、入力された項目を優先してください。
表示崩れを避けるため、各項目の文字数目安を必ず守ってください。
【利用者の夢】
${dream}
【気分】${mood || "不明"}
【登場】${people.join("、") || "不明"}
【色】${colors.join("、") || "不明"}
【利用可能な象徴ID(symbol_id)】
${symbolList}
【22枚の固定カード】
夢に最も合うカードをこの中から1枚選び、romanを返してください。type_code / dream_type_jp / card_name_jp / card_name_en はサイト側でromanに合わせて固定表示されます。
I はじまりの月 / II 静かな鏡 / III 花ひらく庭 / IV 眠れる家 / V 古い時計 / VI 結びの糸 / VII 夜の列車 / VIII 金の鍵 / IX 白猫の駅 / X 星の輪 / XI やさしい獅子 / XII 水底の灯 / XIII ほどける羽 / XIV 虹の杯 / XV 影の小部屋 / XVI ひらく塔 / XVII 願い星 / XVIII 月の使者 / XIX 朝日の扉 / XX 呼ぶ声 / XXI 夢の円環 / XXII 旅する子鹿
【結果ページとの対応 / 出力内容】
結果はサイト上では以下の5項目として表示されます。JSONの各項目にこの役割を持たせてください。
1. 夢の要約分析: main_message。夢本文・気分・人物・色を少なくとも2つ以上具体的に含め、心理状態/未整理感情/回復願望として整理する。
2. 分野別診断(恋愛・仕事・対人): themes.love / themes.work / themes.relations。夢の象徴を各分野に自然に接続する。
3. 象徴分析: keywords。夢に出た物・場所・色・人物を拾い、感情メタファーとして解釈する。
4. 今日のアドバイス: advice。説教せず、今日できる小さな回復行動で安心へ着地する。
5. 夢タイプカード: romanを22枚の固定カードから1枚選ぶ。type_code / dream_type_jp / card_name_jp / card_name_en はサイト側でromanに合わせて固定表示されます。
【出力ルール】
- JSONとしてパース可能な形式だけを返す。コメント、注釈、末尾カンマは禁止。
- 文字列内の改行は避ける。
- symbol_id は必ず上の「利用可能な象徴ID」から選ぶ。存在しないIDを作らない。
- primary_symbol と secondary_symbol は別のIDにする。補助象徴が不要な場合のみ secondary_symbol は空文字にする。
- score は整数。match_score は 85〜99、themes の score は 1〜5。
- aura_color.hex は必ず #RRGGBB。name は和色風の短い名前。
- keywords は3〜5個。夢本文から印象的な語を拾い、wordは2〜5文字程度にする。
【出力フォーマット】
{
"type_code": "(3〜5字。例:M-18, T-09, E-04。romanの番号と雰囲気を合わせる)",
"dream_type_jp": "(5〜8字の日本語の夢タイプ名。例:月の使者型、忘れ鍵型、青い扉型)",
"card_name_jp": "(10字以内の詩的なカード名。例:月の使者、忘れた鍵、青い扉)",
"card_name_en": "(The 〜 形式の短い英語名。例:The Moon's Messenger)",
"roman": "(I〜XXIIのローマ数字から1つ)",
"primary_symbol": "(symbol_idリストから中心象徴を1つ)",
"secondary_symbol": "(symbol_idリストから補助象徴を1つ。不要なら空文字)",
"aura_color": { "hex": "#RRGGBB", "name": "(2〜6字の和色風の色名)" },
"match_score": 92,
"main_message": "(1. 夢の要約分析。90〜130字。夢本文・気分・人物・色のうち入力された内容を必ず具体的に含め、最近の心理状態/心の緊張/未整理感情/回復願望として優しく整理する)",
"themes": {
"love": { "score": 3, "text": "(2. 恋愛・愛情面。30〜45字。夢の象徴を使い、不安を煽らず自己受容へ繋げる)" },
"work": { "score": 3, "text": "(2. 仕事・学び・行動面。30〜45字。緊張や変化への不安を整理する)" },
"relations": { "score": 3, "text": "(2. 対人関係。30〜45字。人物/距離感/安心感に触れる)" }
},
"keywords": [
{ "word": "(3. 夢から拾った物・場所・色・人物)", "symbol_id": "(symbol_idリストから1つ)", "meaning": "(象徴分析。30〜55字。感情メタファーとして解釈)" },
{ "word": "(3. 夢から拾った物・場所・色・人物)", "symbol_id": "(symbol_idリストから1つ)", "meaning": "(象徴分析。30〜55字。感情メタファーとして解釈)" },
{ "word": "(3. 夢から拾った物・場所・色・人物)", "symbol_id": "(symbol_idリストから1つ)", "meaning": "(象徴分析。30〜55字。感情メタファーとして解釈)" }
],
"advice": "(4. 今日のアドバイス。45〜65字。今日できる小さな回復行動。最後は必ず安心感・回復感へ着地)"
}
JSON以外は何も出力しないでください。`;
let raw;
try {
const response = await fetch("/api/dream-diagnosis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dream, mood, people, colors, symbolList })
});
if (!response.ok) throw new Error("api failed");
const data = await response.json();
raw = data.text;
if (!raw) throw new Error("empty result");
} catch (e) {
if (window.claude && typeof window.claude.complete === "function") {
try {
raw = await window.claude.complete(prompt);
} catch (fallbackError) {
throw new Error("夢分析士にうまく届かなかったみたい…");
}
} else {
throw new Error("夢分析士にうまく届かなかったみたい…");
}
}
// strip code fences if any
let text = raw.trim();
if (text.startsWith("```")) {
text = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
}
// grab first { ... last }
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first >= 0 && last > first) text = text.slice(first, last + 1);
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
throw new Error("結果が読み取れませんでした");
}
// validate / fallback fill
parsed.roman = ROMAN.includes(parsed.roman) ? parsed.roman : "XVIII";
const fixedCard = window.getDreamCardByRoman ? window.getDreamCardByRoman(parsed.roman) : null;
parsed.type_code = fixedCard?.code || parsed.type_code || "M-XVIII";
parsed.dream_type_jp = fixedCard?.type || parsed.dream_type_jp || "月の使者型";
parsed.card_name_jp = fixedCard?.nameJp || parsed.card_name_jp || "夢の使者";
parsed.card_name_en = fixedCard?.nameEn || parsed.card_name_en || "The Dream Messenger";
parsed.primary_symbol = parsed.primary_symbol || "moon-crescent";
parsed.secondary_symbol = parsed.secondary_symbol || "";
parsed.aura_color = parsed.aura_color && parsed.aura_color.hex
? parsed.aura_color
: { hex: "#C89255", name: "蜜色" };
parsed.match_score = typeof parsed.match_score === "number" ? parsed.match_score : 92;
parsed.main_message = parsed.main_message || "あなたの夢は、まだ言葉にならない予感を運んでくれました。";
parsed.themes = parsed.themes || {};
["love", "work", "relations"].forEach(k => {
if (!parsed.themes[k]) parsed.themes[k] = { score: 3, text: "" };
});
parsed.keywords = Array.isArray(parsed.keywords) ? parsed.keywords : [];
parsed.advice = parsed.advice || "今日は、心の声に耳を澄ませて。";
return parsed;
}
// ========== App ==========
function App() {
const [phase, setPhase] = useState("input"); // input | loading | result | error
const [reading, setReading] = useState(null);
const [error, setError] = useState(null);
const [pendingInput, setPendingInput] = useState(null);
const [shareOpen, setShareOpen] = useState(false);
const [shareImg, setShareImg] = useState(null); // dataURL
const [shareBusy, setShareBusy] = useState(false);
const shareRef = useRef(null);
const date = useMemo(() => todayInfo(), []);
const generateShareImage = async () => {
setShareOpen(true);
setShareImg(null);
setShareBusy(true);
try {
// wait for fonts + a paint
if (document.fonts && document.fonts.ready) await document.fonts.ready;
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const node = shareRef.current;
if (!node) throw new Error("share node missing");
const canvas = await window.html2canvas(node, {
backgroundColor: null,
scale: 1,
useCORS: true,
logging: false,
width: 1080,
height: 1920,
windowWidth: 1080,
windowHeight: 1920,
});
const dataUrl = canvas.toDataURL("image/png");
setShareImg(dataUrl);
} catch (e) {
console.error("share image gen failed", e);
setShareImg("error");
} finally {
setShareBusy(false);
}
};
const downloadShareImage = () => {
if (!shareImg || shareImg === "error") return;
const a = document.createElement("a");
a.href = shareImg;
a.download = `yumeshindan_${date.iso}.png`;
document.body.appendChild(a);
a.click();
a.remove();
};
const copyShareImage = async () => {
if (!shareImg || shareImg === "error") return;
try {
const blob = await (await fetch(shareImg)).blob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
alert("画像をコピーしました ✨");
} catch (e) {
alert("コピーできませんでした。ダウンロードをお試しください。");
}
};
const onSubmit = async (data) => {
setPendingInput(data);
try {
sessionStorage.setItem("yumeshindan_pending_input", JSON.stringify(data));
} catch (e) {}
setPhase("loading");
setError(null);
try {
const result = await divine(data);
result._image = data.image || null; // attach image for rendering
setReading(result);
setPendingInput(null);
try { sessionStorage.removeItem("yumeshindan_pending_input"); } catch (e) {}
setPhase("result");
setTimeout(() => window.scrollTo({ top: 0, behavior: "smooth" }), 50);
} catch (e) {
setError(e.message || "うまく診断できませんでした");
setPhase("error");
}
};
const retryLastInput = () => {
if (pendingInput) {
onSubmit(pendingInput);
return;
}
try {
const saved = JSON.parse(sessionStorage.getItem("yumeshindan_pending_input") || "null");
if (saved?.dream) {
onSubmit(saved);
return;
}
} catch (e) {}
setPhase("input");
};
const onReset = () => {
setReading(null);
setPendingInput(null);
try { sessionStorage.removeItem("yumeshindan_pending_input"); } catch (e) {}
setPhase("input");
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
{phase === "input" &&
}
{phase === "loading" && }
{phase === "result" && reading && (
)}
{phase === "error" && (
)}
✦Made with care for your morning✦
{/* Offscreen share-card for capture */}
{reading && (
)}
{shareOpen && (
setShareOpen(false)}
onDownload={downloadShareImage}
onCopy={copyShareImage}
/>
)}
);
}
// ========== ShareModal ==========
function ShareModal({ img, busy, onClose, onDownload, onCopy }) {
return (
e.stopPropagation()}>
{busy || !img ? (
画像を生成中
) : img === "error" ? (
画像が作れませんでした…
) : (

)}
);
}
window.App = App;