Skip to content

Commit b6ab4bc

Browse files
committed
ADD - journaling for rev
1 parent e94ee7b commit b6ab4bc

7 files changed

Lines changed: 492 additions & 5 deletions

File tree

client/src/App.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TestPage } from "./components/TestPage.jsx";
1111
import { AdminAirtableSyncPage } from "./components/AdminAirtableSyncPage.jsx";
1212
import { AdminPage } from "./components/AdminPage.jsx";
1313
import { AdminReviewPage } from "./components/AdminReviewPage.jsx";
14+
import { JournalingRecordsPage } from "./components/JournalingRecordsPage.jsx";
1415
import { AdminShopPage } from "./components/AdminShopPage.jsx";
1516
import { AdminShopOrdersPage } from "./components/AdminShopOrdersPage.jsx";
1617
import { AdminStatsPage } from "./components/AdminStatsPage.jsx";
@@ -115,6 +116,9 @@ export default function App() {
115116
case "/rules":
116117
page = <RulesPage />;
117118
break;
119+
case "/journalingrecords":
120+
page = <JournalingRecordsPage />;
121+
break;
118122
case "/user":
119123
page = <UserAreaPage />;
120124
break;

client/src/components/JournalDescription.jsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
parseJournalDescription,
66
} from "../utils/mediaUrls.js";
77

8-
function JournalMedia({ url, rawUrl, alt, className }) {
9-
const apiSrc = journalDisplaySrc({ resolved: url, rawUrl, alt });
8+
function JournalMedia({ url, rawUrl, alt, className, mediaEndpoint }) {
9+
const apiSrc = journalDisplaySrc({ resolved: url, rawUrl, alt, mediaEndpoint });
1010
const [objectUrl, setObjectUrl] = useState(null);
1111
const [failed, setFailed] = useState(false);
1212
const [loading, setLoading] = useState(Boolean(apiSrc));
@@ -83,7 +83,12 @@ function JournalMedia({ url, rawUrl, alt, className }) {
8383
);
8484
}
8585

86-
export function JournalDescription({ text, className = "journal-description", mediaClassName = "journal-media-item" }) {
86+
export function JournalDescription({
87+
text,
88+
className = "journal-description",
89+
mediaClassName = "journal-media-item",
90+
mediaEndpoint,
91+
}) {
8792
const parts = parseJournalDescription(text);
8893
if (parts.length === 0) return null;
8994

@@ -101,6 +106,7 @@ export function JournalDescription({ text, className = "journal-description", me
101106
rawUrl={part.rawUrl}
102107
alt={part.alt}
103108
className={mediaClassName}
109+
mediaEndpoint={mediaEndpoint}
104110
/>
105111
)
106112
)}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
import { JournalDescription } from "./JournalDescription.jsx";
3+
import { readJsonResponse } from "../utils/fetchJson.js";
4+
import "./AdminReviewPage.css";
5+
6+
const JOURNALING_MEDIA_ENDPOINT = "/api/journalingrecords/media";
7+
8+
function slackDisplay(user) {
9+
return user?.slug ? `@${user.slug}` : user?.email || "Unknown";
10+
}
11+
12+
export function JournalingRecordsPage() {
13+
const [configured, setConfigured] = useState(true);
14+
const [authenticated, setAuthenticated] = useState(false);
15+
const [checkingSession, setCheckingSession] = useState(true);
16+
const [password, setPassword] = useState("");
17+
const [authError, setAuthError] = useState("");
18+
const [authBusy, setAuthBusy] = useState(false);
19+
const [groups, setGroups] = useState([]);
20+
const [search, setSearch] = useState("");
21+
const [status, setStatus] = useState("");
22+
const [error, setError] = useState("");
23+
24+
useEffect(() => {
25+
const meta = document.createElement("meta");
26+
meta.name = "robots";
27+
meta.content = "noindex,nofollow";
28+
document.head.appendChild(meta);
29+
return () => {
30+
document.head.removeChild(meta);
31+
};
32+
}, []);
33+
34+
useEffect(() => {
35+
checkSession();
36+
}, []);
37+
38+
useEffect(() => {
39+
if (authenticated) {
40+
loadRecords();
41+
}
42+
}, [authenticated]);
43+
44+
async function checkSession() {
45+
setCheckingSession(true);
46+
try {
47+
const response = await fetch("/api/journalingrecords/session", { credentials: "include" });
48+
if (response.status === 503) {
49+
setConfigured(false);
50+
setAuthenticated(false);
51+
return;
52+
}
53+
const data = await readJsonResponse(response);
54+
setConfigured(true);
55+
setAuthenticated(Boolean(data.authenticated));
56+
} catch {
57+
setConfigured(false);
58+
setAuthenticated(false);
59+
} finally {
60+
setCheckingSession(false);
61+
}
62+
}
63+
64+
async function submitPassword(event) {
65+
event.preventDefault();
66+
setAuthBusy(true);
67+
setAuthError("");
68+
try {
69+
const response = await fetch("/api/journalingrecords/auth", {
70+
method: "POST",
71+
credentials: "include",
72+
headers: { "Content-Type": "application/json" },
73+
body: JSON.stringify({ password }),
74+
});
75+
const data = await readJsonResponse(response);
76+
if (!response.ok) throw new Error(data.error || "Incorrect password.");
77+
setAuthenticated(true);
78+
setPassword("");
79+
} catch (err) {
80+
setAuthError(err.message);
81+
} finally {
82+
setAuthBusy(false);
83+
}
84+
}
85+
86+
async function loadRecords() {
87+
setStatus("Loading journal entries...");
88+
setError("");
89+
try {
90+
const response = await fetch("/api/journalingrecords", { credentials: "include" });
91+
if (response.status === 401) {
92+
setAuthenticated(false);
93+
setGroups([]);
94+
throw new Error("Session expired. Enter the password again.");
95+
}
96+
const data = await readJsonResponse(response);
97+
if (!response.ok) throw new Error(data.error || "Failed to load journal entries.");
98+
setGroups(data.groups || []);
99+
setStatus("");
100+
} catch (err) {
101+
setError(err.message);
102+
setStatus("");
103+
}
104+
}
105+
106+
const filteredGroups = useMemo(() => {
107+
const needle = search.trim().toLowerCase();
108+
if (!needle) return groups;
109+
110+
return groups
111+
.map((group) => {
112+
const userBlob = [group.user?.email, group.user?.slug].filter(Boolean).join(" ");
113+
const groupBlob = `${group.projectName} ${userBlob}`.toLowerCase();
114+
const matchingEntries = group.entries.filter((entry) => {
115+
const entryBlob = [
116+
entry.description,
117+
entry.toolsUsed?.join(" "),
118+
entry.timeDone,
119+
]
120+
.filter(Boolean)
121+
.join(" ")
122+
.toLowerCase();
123+
return entryBlob.includes(needle) || groupBlob.includes(needle);
124+
});
125+
if (matchingEntries.length === 0 && !groupBlob.includes(needle)) return null;
126+
return {
127+
...group,
128+
entries: matchingEntries.length > 0 ? matchingEntries : group.entries,
129+
};
130+
})
131+
.filter(Boolean);
132+
}, [groups, search]);
133+
134+
const totalEntries = useMemo(
135+
() => filteredGroups.reduce((sum, group) => sum + group.entries.length, 0),
136+
[filteredGroups]
137+
);
138+
139+
if (checkingSession) {
140+
return (
141+
<main className="admin-review-page">
142+
<section className="admin-review-container">
143+
<p className="admin-review-state">Loading…</p>
144+
</section>
145+
</main>
146+
);
147+
}
148+
149+
if (!configured) {
150+
return (
151+
<main className="admin-review-page">
152+
<section className="admin-review-container">
153+
<header className="admin-review-header">
154+
<h1>Journaling Records</h1>
155+
<p className="admin-review-state admin-review-state--error">
156+
Access is not configured. Set <code>journalingPW</code> in the server environment.
157+
</p>
158+
</header>
159+
</section>
160+
</main>
161+
);
162+
}
163+
164+
if (!authenticated) {
165+
return (
166+
<main className="admin-review-page">
167+
<section className="admin-review-container">
168+
<header className="admin-review-header">
169+
<h1>Journaling Records</h1>
170+
<p>Enter the password to view all journal entries.</p>
171+
</header>
172+
<form className="admin-review-panel" onSubmit={submitPassword}>
173+
<label className="admin-review-feedback">
174+
Password
175+
<input
176+
className="admin-review-search"
177+
type="password"
178+
value={password}
179+
autoComplete="current-password"
180+
onChange={(event) => setPassword(event.target.value)}
181+
/>
182+
</label>
183+
{authError ? <p className="admin-review-state admin-review-state--error">{authError}</p> : null}
184+
<button className="admin-review-submit" type="submit" disabled={authBusy || !password}>
185+
{authBusy ? "Checking…" : "Continue"}
186+
</button>
187+
</form>
188+
</section>
189+
</main>
190+
);
191+
}
192+
193+
return (
194+
<main className="admin-review-page">
195+
<section className="admin-review-container">
196+
<header className="admin-review-header">
197+
<h1>Journaling Records</h1>
198+
<p>
199+
All journal entries across projects ({totalEntries} entries in {filteredGroups.length} projects).
200+
</p>
201+
<button
202+
className="admin-review-tool-btn"
203+
type="button"
204+
onClick={async () => {
205+
await fetch("/api/journalingrecords/logout", {
206+
method: "POST",
207+
credentials: "include",
208+
});
209+
setAuthenticated(false);
210+
setGroups([]);
211+
}}
212+
>
213+
Lock page
214+
</button>
215+
</header>
216+
217+
<input
218+
className="admin-review-search"
219+
value={search}
220+
onChange={(event) => setSearch(event.target.value)}
221+
placeholder="Search by project, user, email, description, tools..."
222+
/>
223+
224+
{status ? <p className="admin-review-state">{status}</p> : null}
225+
{error ? <p className="admin-review-state admin-review-state--error">{error}</p> : null}
226+
{!status && !error && filteredGroups.length === 0 ? (
227+
<p className="admin-review-state">No journal entries found.</p>
228+
) : null}
229+
230+
{filteredGroups.map((group) => (
231+
<section className="admin-review-panel" key={`${group.projectName}-${group.user?.email ?? "unknown"}`}>
232+
<h2>
233+
{group.projectName}
234+
<span className="admin-review-participant-name">
235+
{" "}
236+
· {group.user?.email || "No email on file"}
237+
{group.user?.slug ? ` · @${group.user.slug}` : ""}
238+
</span>
239+
</h2>
240+
<p className="admin-review-participant-line">
241+
<strong>{slackDisplay(group.user)}</strong>
242+
<span className="admin-review-participant-name"> · {group.entries.length} entries</span>
243+
</p>
244+
{group.entries.length === 0 ? (
245+
<p>No journal entries for this project.</p>
246+
) : (
247+
group.entries.map((entry) => (
248+
<article className="admin-review-journal-entry" key={entry.id}>
249+
<strong>
250+
{entry.timeDone ? new Date(entry.timeDone).toLocaleString() : "N/A"} · {entry.hoursWorked}h
251+
</strong>
252+
<JournalDescription
253+
text={entry.description}
254+
className="admin-review-journal-description"
255+
mediaClassName="admin-review-media-item"
256+
mediaEndpoint={JOURNALING_MEDIA_ENDPOINT}
257+
/>
258+
{entry.toolsUsed?.length ? <small>Tools: {entry.toolsUsed.join(", ")}</small> : null}
259+
</article>
260+
))
261+
)}
262+
</section>
263+
))}
264+
</section>
265+
</main>
266+
);
267+
}

client/src/utils/mediaUrls.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ function pushMediaPart(parts, alt, rawUrl) {
140140
* Staff review / journal images: always load via server proxy so prod works
141141
* (CDN encoding, hotlinking, dev URLs → local /uploads path, filename lookup).
142142
*/
143-
export function journalDisplaySrc({ resolved, rawUrl, alt }) {
143+
export function journalDisplaySrc({ resolved, rawUrl, alt, mediaEndpoint = "/api/admin/review/media" }) {
144144
if (typeof window === "undefined") return resolved || null;
145145

146146
const origin = window.location.origin;
@@ -155,7 +155,7 @@ export function journalDisplaySrc({ resolved, rawUrl, alt }) {
155155
}
156156

157157
if ([...params.keys()].length === 0) return null;
158-
return `${origin}/api/admin/review/media?${params.toString()}`;
158+
return `${origin}${mediaEndpoint}?${params.toString()}`;
159159
}
160160

161161
function collectMarkdownRanges(text) {

0 commit comments

Comments
 (0)