| |
| import { useEffect, useState, type ReactNode } from "react" |
| import { RiArrowDownSLine, RiHeartLine, RiPhoneLine } from "@remixicon/react" |
| import { Button } from "@/components/ui/button" |
| import { contentLang } from "@/lib/locale" |
|
|
| |
| export function CrisisPanel({ message, helplines, onClose }: { |
| message: string |
| helplines: { name: string; tel: string }[] |
| onClose: () => void |
| }) { |
| return ( |
| <div className="rounded-2xl border p-8 text-left" |
| style={{ background: "rgba(163,91,91,0.07)", borderColor: "#A35B5B" }}> |
| <div className="mb-4 flex items-center gap-2.5" style={{ color: "#A35B5B" }}> |
| <RiHeartLine className="h-6 w-6" /> |
| <span className="text-base tracking-[0.25em]">先停一停</span> |
| </div> |
| <p className="text-sm leading-7 whitespace-pre-wrap">{message.split("\n\n")[0] || message}</p> |
| <div className="my-5 space-y-1"> |
| {helplines.map((h) => ( |
| <a key={h.tel} href={`tel:${h.tel.replace(/\s/g, "")}`} |
| className="hover:bg-foreground/5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm tracking-wide transition-colors" |
| style={{ color: "var(--gold)" }}> |
| <RiPhoneLine className="h-4 w-4 shrink-0" /> {h.name} | {h.tel} |
| </a> |
| ))} |
| </div> |
| <p className="text-muted-foreground mb-5 text-xs leading-6"> |
| 这不是命理问题,而是此刻最该被认真对待的事。你不必独自面对。 |
| </p> |
| <Button variant="outline" onClick={onClose}>回到安静处</Button> |
| </div> |
| ) |
| } |
|
|
| |
| export function Foldable({ label, children, defaultOpen = false }: { |
| label: string |
| children: React.ReactNode |
| defaultOpen?: boolean |
| }) { |
| const [open, setOpen] = useState(defaultOpen) |
| return ( |
| <div className="mx-auto w-full"> |
| <div className="flex justify-center"> |
| <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} |
| className="text-muted-foreground hover:text-foreground group flex items-center gap-2.5 py-2 transition-colors"> |
| <span className="bg-border h-px w-8 transition-all group-hover:w-10" /> |
| <span className="text-xs tracking-[0.3em]">{open ? label.replace(/^展开/, "收起") : label}</span> |
| <RiArrowDownSLine className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} /> |
| <span className="bg-border h-px w-8 transition-all group-hover:w-10" /> |
| </button> |
| </div> |
| {open && <div className="fade-in mt-5">{children}</div>} |
| </div> |
| ) |
| } |
|
|
| |
| |
| function renderInline(text: string): ReactNode[] { |
| const out: ReactNode[] = [] |
| const re = /\*\*([^*]+)\*\*|\*([^*\n]+)\*|__([^_]+)__/g |
| let last = 0, key = 0, m: RegExpExecArray | null |
| while ((m = re.exec(text)) !== null) { |
| if (m.index > last) out.push(text.slice(last, m.index)) |
| out.push( |
| <strong key={key++} className="text-foreground font-semibold"> |
| {m[1] ?? m[2] ?? m[3]} |
| </strong>, |
| ) |
| last = re.lastIndex |
| } |
| if (last < text.length) out.push(text.slice(last)) |
| return out |
| } |
|
|
| export function Prose({ text, className = "" }: { text: string; className?: string }) { |
| let t = text |
| |
| if (contentLang() === "en") t = t.replace(/今日可行的一小步\s*[::]?\s*/g, "Today's small step: ") |
| const paras = t.split(/\n+/).map((s) => s.trim()).filter(Boolean) |
| return ( |
| <div className={`space-y-3 text-sm leading-[1.85] ${className}`}> |
| {paras.map((p, i) => <p key={i}>{renderInline(p)}</p>)} |
| </div> |
| ) |
| } |
|
|
| |
| export function InkText({ text }: { text: string }) { |
| const [n, setN] = useState(0) |
| useEffect(() => { |
| |
| |
| setN(0) |
| const t = setInterval(() => { |
| setN((x) => { |
| if (x >= text.length) { |
| clearInterval(t) |
| return x |
| } |
| return x + 1 |
| }) |
| }, 18) |
| return () => clearInterval(t) |
| }, [text]) |
| return <div className="text-sm whitespace-pre-wrap">{text.slice(0, n)}</div> |
| } |
|
|
| |
| export function InkLoading({ label = "推演中" }: { label?: string }) { |
| return ( |
| <div className="ink-loading"> |
| <span className="ink-blot" /> |
| <span className="ink-blot" /> |
| <span className="ink-blot" /> |
| <span className="ink-loading-label">{label}</span> |
| </div> |
| ) |
| } |
|
|
| |
| export function OfflineBadge() { |
| const [offline, setOffline] = useState(typeof navigator !== "undefined" && !navigator.onLine) |
| useEffect(() => { |
| const on = () => setOffline(false) |
| const off = () => setOffline(true) |
| window.addEventListener("online", on) |
| window.addEventListener("offline", off) |
| return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off) } |
| }, []) |
| if (!offline) return null |
| return ( |
| <div className="glass-heavy fixed top-3 left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-1.5 text-xs tracking-[0.2em]" |
| style={{ color: "var(--gold)" }}> |
| 离线中 | 本地推演仍可用 |
| </div> |
| ) |
| } |
|
|
| export function CardLabel({ children }: { children: React.ReactNode }) { |
| return <div className="mb-3 text-[0.95rem] font-medium tracking-[0.18em]" style={{ color: "var(--gold)" }}>{children}</div> |
| } |
|
|
| export function ZoneTitle({ children }: { children: React.ReactNode }) { |
| return ( |
| <h3 className="text-foreground mb-7 text-lg font-medium tracking-[0.4em]"> |
| {children} |
| <span className="mt-3 block h-px w-10 opacity-50" style={{ background: "var(--gold)" }} /> |
| </h3> |
| ) |
| } |
|
|