File size: 6,437 Bytes
2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 af6cd33 2f00f43 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | import { useEffect, useState } from "react";
import { forgesight } from "@/lib/api";
import { toast } from "sonner";
import { Twitter, Linkedin, Copy, Plus, Sparkles } from "lucide-react";
export default function Journal() {
const [items, setItems] = useState([]);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [busy, setBusy] = useState(false);
const load = async () => {
try {
const data = await forgesight.listJournal();
setItems(data.items || []);
if ((data.items || []).length === 0) {
await forgesight.seedJournal();
const r = await forgesight.listJournal();
setItems(r.items || []);
}
} catch {}
};
useEffect(() => {
load();
}, []);
const submit = async () => {
if (!title.trim() || !body.trim()) {
toast.error("Title + body required");
return;
}
setBusy(true);
try {
const data = await forgesight.createJournal({
title,
body,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
setItems((prev) => [data, ...prev]);
setTitle("");
setBody("");
setTags("");
toast.success("Milestone logged + social drafts generated");
} catch (e) {
toast.error(e?.response?.data?.detail || "Failed to log milestone");
} finally {
setBusy(false);
}
};
const copy = async (text, label) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied`);
} catch {
toast.error("Copy failed");
}
};
return (
<div className="mx-auto max-w-[1400px] px-6 py-10" data-testid="journal-page">
<header className="mb-8">
<div className="fs-label mb-3">§ JOURNAL · BUILD-IN-PUBLIC</div>
<h1 className="font-display font-black tracking-tighter text-4xl md:text-5xl">Build Journal</h1>
<p className="text-zinc-400 mt-3 max-w-2xl">
Every milestone auto-drafts social posts — X + LinkedIn — ready to ship, hashtags and AMD / lablab mentions baked in.
</p>
</header>
<div className="grid lg:grid-cols-12 gap-6">
{/* Composer */}
<aside className="lg:col-span-4">
<div className="border border-white/10 bg-[#141416] p-5 fs-corners sticky top-20" data-testid="journal-composer">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-3.5 h-3.5 text-[#ED1C24]" />
<span className="fs-label">New milestone</span>
</div>
<div className="space-y-3">
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title…"
className="w-full bg-[#0A0A0A] border border-white/10 focus:border-[#ED1C24] outline-none px-3 py-2 font-mono text-sm"
data-testid="journal-title-input" />
<textarea value={body} onChange={(e) => setBody(e.target.value)} rows={5} placeholder="What happened today?"
className="w-full bg-[#0A0A0A] border border-white/10 focus:border-[#ED1C24] outline-none px-3 py-2 font-mono text-sm"
data-testid="journal-body-input" />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="tags, comma, separated"
className="w-full bg-[#0A0A0A] border border-white/10 focus:border-[#ED1C24] outline-none px-3 py-2 font-mono text-sm"
data-testid="journal-tags-input" />
<button disabled={busy} onClick={submit}
className="fs-btn fs-btn-primary w-full inline-flex items-center justify-center gap-2 disabled:opacity-50"
data-testid="journal-submit-btn">
{busy ? (<>Generating drafts<span className="fs-cursor" /></>) : (<><Plus className="w-4 h-4" /> Log + draft posts</>)}
</button>
</div>
</div>
</aside>
{/* Timeline */}
<section className="lg:col-span-8 space-y-5" data-testid="journal-timeline">
{items.length === 0 && (
<div className="border border-white/10 bg-[#141416] p-10 text-center font-mono text-sm text-zinc-500">
No entries yet. Log your first milestone →
</div>
)}
{items.map((e) => (
<article key={e.id} className="border border-white/10 bg-[#141416] p-6 fs-rise" data-testid={`journal-entry-${e.id}`}>
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2">
<span className="fs-chip fs-chip-fail">{new Date(e.created_at).toLocaleDateString()}</span>
{e.tags?.map((t) => (<span key={t} className="fs-chip">#{t}</span>))}
</div>
</div>
<h3 className="font-display font-black tracking-tight text-xl mb-2">{e.title}</h3>
<p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-line">{e.body}</p>
<div className="grid md:grid-cols-2 gap-3 mt-5">
{e.x_post && (
<SocialCard icon={Twitter} label="X POST" text={e.x_post}
onCopy={() => copy(e.x_post, "X post")} testid={`x-post-${e.id}`} />
)}
{e.linkedin_post && (
<SocialCard icon={Linkedin} label="LINKEDIN POST" text={e.linkedin_post}
onCopy={() => copy(e.linkedin_post, "LinkedIn post")} testid={`li-post-${e.id}`} />
)}
</div>
</article>
))}
</section>
</div>
</div>
);
}
function SocialCard({ icon: Icon, label, text, onCopy, testid }) {
return (
<div className="border border-white/10 bg-[#0A0A0A] p-4" data-testid={testid}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon className="w-3.5 h-3.5 text-[#ED1C24]" />
<span className="fs-label">{label}</span>
</div>
<button onClick={onCopy} className="fs-chip hover:text-white hover:border-white/40 inline-flex items-center gap-1">
<Copy className="w-3 h-3" /> copy
</button>
</div>
<div className="font-mono text-xs text-zinc-300 leading-relaxed whitespace-pre-line">{text}</div>
</div>
);
}
|