Samfredoly commited on
Commit
2fca7e3
·
verified ·
1 Parent(s): 1cbd113

Persist submissions in JSON and add owner room

Browse files
Files changed (7) hide show
  1. .dockerignore +2 -0
  2. Dockerfile +2 -0
  3. plan.md +2 -39
  4. public/manus-routes.json +1 -1
  5. public/owner.html +27 -0
  6. public/owner.js +73 -0
  7. server.js +49 -12
.dockerignore CHANGED
@@ -1,6 +1,8 @@
1
  node_modules
2
  .git
3
  dist
 
 
4
  .env
5
  .env.*
6
  !.env.example
 
1
  node_modules
2
  .git
3
  dist
4
+ data
5
+ submissions.json
6
  .env
7
  .env.*
8
  !.env.example
Dockerfile CHANGED
@@ -10,6 +10,8 @@ RUN npm run build
10
 
11
  ENV NODE_ENV=production
12
  ENV PORT=7860
 
 
13
  EXPOSE 7860
14
 
15
  CMD ["node", "server.js"]
 
10
 
11
  ENV NODE_ENV=production
12
  ENV PORT=7860
13
+ ENV DATA_DIR=/data
14
+ RUN mkdir -p /data
15
  EXPOSE 7860
16
 
17
  CMD ["node", "server.js"]
plan.md CHANGED
@@ -1,40 +1,3 @@
1
- # Second Chance — implementation plan
2
 
3
- ## Product scope
4
- Build a responsive, emotionally warm interactive reconciliation journey inspired by the supplied dating-app landing-page reference without copying it. The page guides a woman through a heartfelt apology, personal promises, name entry, and explicit consent before presenting the man with a clear, pressure-free final decision.
5
-
6
- ## Design direction
7
- - **Design movement:** editorial glassmorphism with soft 3D object art and “digital keepsake” romance.
8
- - **Core principles:** emotional honesty over persuasion; layered depth; playful but grown-up microcopy; consent is visible and never implied.
9
- - **Color philosophy:** parchment, cocoa, clay, and rosewood create a warmer, more intimate atmosphere; espresso typography keeps the page grounded; deep red is reserved for heart/affirmation moments and muted brass marks repair rather than perfection.
10
- - **Layout paradigm:** an unfolding vertical story rather than a centered marketing grid. Oversized type, offset cards, sticky story markers, and wide breathing room create a scroll-led narrative.
11
- - **Signature elements:** repaired floating heart, handwritten-style promise notes, thin chapter rail / progress marker.
12
- - **Interaction philosophy:** each action should feel like a small act of sincerity: choose a promise, write a name, opt in explicitly, then let the other person decide.
13
- - **Animation:** restrained parallax on background ribbons and heart, springy chips, soft card reveals, reduced-motion fallback, no confetti until acceptance.
14
- - **Typography:** use a high-contrast serif display face for emotional headlines and a clean grotesk sans for UI/body. Use `Cormorant Garamond` + `Manrope` from Google Fonts.
15
- - **Brand essence:** a private second-chance ritual for two people who want to try again honestly — tender, lucid, a little cheeky.
16
- - **Brand voice:** intimate, direct, self-aware. Example lines: “A heart can open without forgetting.” / “No grand gesture. Just a clear yes, if it’s really a yes.”
17
- - **Wordmark & logo:** `SC` monogram inside a small broken-and-rejoined ring, paired with the lowercase wordmark `second/chance`.
18
- - **Signature brand color:** `#A51F2B` rosewood red.
19
-
20
- ## Implementation approach
21
- - Vite + vanilla JavaScript for a small, dependency-light interactive page.
22
- - `index.html` contains semantic sections and a single app shell.
23
- - `src/main.js` owns step state, promise selection, name entry, consent gating, final decision, parallax listeners, and reduced-motion behavior.
24
- - `src/styles.css` owns responsive editorial layout, glass effects, typography, responsive breakpoints, and animation.
25
- - `public/manus-routes.json` declares the root route for Preview/publishing tooling.
26
- - `public/second-chance-hero.png` will hold the generated 3D hero illustration.
27
-
28
- ## Project structure
29
- - `index.html` — document shell, metadata, app mount.
30
- - `src/main.js` — stateful interaction flow and progressive rendering.
31
- - `src/styles.css` — all visual design and responsive behavior.
32
- - `public/manus-routes.json` — route manifest.
33
- - `app.config.ts` — project logo metadata.
34
- - `TODO.md` — outcome clauses tracked for implementation.
35
-
36
- ## Reconnection journey update
37
- The page is organized as four active chapters — notice the gap, rebuild the bridge, make it real, and leave room — with a sticky progress navigator, animated bridge dividers, scroll-aware active states, pointer-reactive hero art, and reduced-motion-aware transitions. Completing the third promise gently advances the user into the named apology chapter instead of leaving the form as a static page.
38
-
39
- ## Sincerity and privacy extension
40
- The journey now collects a short accountable apology in addition to the promise bouquet. After consent, the server posts the bounded answer payload to OpenRouter using `cohere/north-mini-code:free` and asks for a nuanced, non-certainty-based sincerity impression. The UI labels it as an impression rather than a lie detector and shows a visibly rough editorial result card. If the provider rejects the configured credential or is unavailable, the endpoint returns a clearly labeled offline reflection instead of pretending a model ran. The exact answers and reading are sealed after submission and only revealed after the server checks the owner-only `OWNER_VIEW_PASSWORD` secret; neither secret is shipped to frontend code.
 
 
1
 
2
+ ## Persistent answer room
3
+ Submissions are written atomically to JSON at `DATA_DIR/submissions.json`, defaulting to `/data` when available and falling back to the project data directory locally. The `/owner` dashboard and `/api/owner/submissions` endpoint provide cross-device access without a login, per request. This is intentionally private-by-URL rather than authenticated: anyone who knows the owner URL can read the records. A mounted persistent volume is required for the JSON file to survive Hugging Face container replacement.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/manus-routes.json CHANGED
@@ -1 +1 @@
1
- {"routes":[{"path":"/","title":"Second Chance"}]}
 
1
+ {"routes":[{"path":"/","title":"Second Chance"},{"path":"/owner","title":"Private Room"}]}
public/owner.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#f6ede6" />
7
+ <title>second/chance — private room</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
11
+ <style>
12
+ :root{--ink:#2b1a16;--muted:#7a5b52;--paper:#f6ede6;--card:rgba(255,250,245,.72);--red:#a51f2b;--line:rgba(43,26,22,.16);--serif:'Cormorant Garamond',Georgia,serif;--sans:'Manrope',Arial,sans-serif;--mono:'DM Mono',monospace}*{box-sizing:border-box}body{margin:0;background:linear-gradient(140deg,#f2dfd2,#f0e0d4 52%,#d7b9a8);color:var(--ink);font-family:var(--sans)}main{width:min(1100px,calc(100% - 40px));margin:0 auto;padding:34px 0 80px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:70px}.brand{font-weight:800;font-size:14px}.brand span{color:var(--red)}.back{font:10px var(--mono);text-transform:uppercase;color:var(--muted);text-decoration:none}.eyebrow,.meta{font:10px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}h1{font:600 clamp(60px,9vw,120px)/.82 var(--serif);letter-spacing:-.06em;margin:0 0 24px}h1 em{color:var(--red);font-weight:500}.lede{max-width:510px;color:var(--muted);line-height:1.7;margin-bottom:44px}.notice{border:1px solid var(--line);background:rgba(255,250,245,.46);padding:16px 20px;max-width:700px;font-size:12px;line-height:1.6;margin-bottom:28px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:24px 0}.count{font:11px var(--mono);color:var(--muted);text-transform:uppercase}.button{border:0;border-radius:999px;background:var(--ink);color:white;padding:12px 18px;font-weight:800;cursor:pointer}.empty{padding:42px;border:1px dashed var(--line);color:var(--muted)}.grid{display:grid;gap:18px}.entry{background:var(--card);border:1px solid rgba(255,250,245,.82);box-shadow:12px 14px 0 rgba(164,91,79,.18);padding:24px;border-radius:2px 24px 2px 24px}.entry-head{display:flex;justify-content:space-between;gap:20px;align-items:start;border-bottom:1px solid var(--line);padding-bottom:15px;margin-bottom:18px}.entry h2{font:600 34px/.95 var(--serif);margin:0}.entry time{font:10px var(--mono);color:var(--muted);text-align:right}.label{font:10px var(--mono);text-transform:uppercase;color:var(--red);letter-spacing:.05em;margin:18px 0 7px}.answer{white-space:pre-wrap;line-height:1.65;font-size:14px;margin:0}.pills{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:12px;border:1px solid var(--line);padding:8px 10px;border-radius:999px;background:rgba(255,255,255,.3)}.review{border-left:3px solid var(--red);padding:13px 16px;background:rgba(165,31,43,.06);margin-top:8px}.score{font:700 22px var(--serif);color:var(--red)}.source{font:10px var(--mono);text-transform:uppercase;color:var(--muted);margin-left:8px}.status{font:10px var(--mono);text-transform:uppercase;color:var(--muted)}@media(max-width:600px){main{width:calc(100% - 28px);padding-top:20px}.top{margin-bottom:52px}.entry-head{display:block}.entry time{display:block;text-align:left;margin-top:10px}.toolbar{align-items:start;flex-direction:column}}
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <main>
17
+ <div class="top"><div class="brand">second<span>/</span>chance</div><a class="back" href="/">← back to the room</a></div>
18
+ <p class="eyebrow">private room / no login enabled</p>
19
+ <h1>What she<br /><em>said.</em></h1>
20
+ <p class="lede">Every answer submitted through the journey is collected here, so you can read it from your own device after she finishes.</p>
21
+ <div class="notice"><strong>Privacy note:</strong> this dashboard has no login, as requested. Anyone who knows this URL may be able to read these private answers. The JSON store is persistent only on the Space filesystem or mounted <code>/data</code> volume.</div>
22
+ <div class="toolbar"><span class="count" id="count">loading the room…</span><button class="button" id="refresh" type="button">refresh answers ↻</button></div>
23
+ <section class="grid" id="entries" aria-live="polite"></section>
24
+ </main>
25
+ <script src="/owner.js"></script>
26
+ </body>
27
+ </html>
public/owner.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const entries = document.querySelector('#entries');
2
+ const count = document.querySelector('#count');
3
+ const refresh = document.querySelector('#refresh');
4
+
5
+ const text = (value) => String(value ?? '');
6
+ const formatDate = (value) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
7
+
8
+ function addText(parent, tag, className, value) {
9
+ const node = document.createElement(tag);
10
+ node.className = className;
11
+ node.textContent = text(value);
12
+ parent.appendChild(node);
13
+ return node;
14
+ }
15
+
16
+ function renderSubmission(item) {
17
+ const article = document.createElement('article');
18
+ article.className = 'entry';
19
+ const head = document.createElement('div');
20
+ head.className = 'entry-head';
21
+ addText(head, 'h2', '', item.name || 'Unnamed answer');
22
+ addText(head, 'time', '', formatDate(item.createdAt));
23
+ article.appendChild(head);
24
+ addText(article, 'div', 'label', 'the hard part');
25
+ addText(article, 'p', 'answer', item.apology);
26
+ addText(article, 'div', 'label', 'promises');
27
+ const pills = document.createElement('div');
28
+ pills.className = 'pills';
29
+ (Array.isArray(item.promises) ? item.promises : []).forEach((promise) => addText(pills, 'span', 'pill', promise));
30
+ article.appendChild(pills);
31
+ addText(article, 'div', 'label', 'consent');
32
+ addText(article, 'p', 'status', item.consent ? 'given — she said she means it and respects your choice' : 'not given');
33
+ const review = document.createElement('div');
34
+ review.className = 'review';
35
+ addText(review, 'div', 'label', 'imperfect AI reflection');
36
+ const score = document.createElement('div');
37
+ score.className = 'score';
38
+ score.textContent = `${item.reading?.score ?? '—'} / 100`;
39
+ const source = document.createElement('span');
40
+ source.className = 'source';
41
+ source.textContent = item.reading?.source === 'model' ? 'model-backed' : 'offline reflection';
42
+ score.appendChild(source);
43
+ review.appendChild(score);
44
+ addText(review, 'p', 'answer', item.reading?.read || 'No reading saved.');
45
+ addText(review, 'p', 'status', `${item.reading?.label || 'unlabeled'} · care signal: ${item.reading?.careSignal || 'not recorded'}`);
46
+ article.appendChild(review);
47
+ return article;
48
+ }
49
+
50
+ async function load() {
51
+ count.textContent = 'loading the room…';
52
+ refresh.disabled = true;
53
+ try {
54
+ const response = await fetch('/api/owner/submissions', { cache: 'no-store' });
55
+ if (!response.ok) throw new Error('Could not load the room.');
56
+ const payload = await response.json();
57
+ entries.replaceChildren();
58
+ const list = Array.isArray(payload.submissions) ? payload.submissions : [];
59
+ count.textContent = `${list.length} sealed answer${list.length === 1 ? '' : 's'} · newest first`;
60
+ if (!list.length) {
61
+ addText(entries, 'div', 'empty', 'No one has answered yet. When she finishes the room, her words will appear here.');
62
+ } else {
63
+ list.forEach((item) => entries.appendChild(renderSubmission(item)));
64
+ }
65
+ } catch (error) {
66
+ count.textContent = error.message;
67
+ } finally {
68
+ refresh.disabled = false;
69
+ }
70
+ }
71
+
72
+ refresh.addEventListener('click', load);
73
+ load();
server.js CHANGED
@@ -9,7 +9,26 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
  const port = Number(process.env.PORT || 3000);
10
  const isProduction = process.env.NODE_ENV === 'production';
11
  const model = 'cohere/north-mini-code:free';
12
- const submissions = new Map();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  const fallbackReading = {
15
  score: 58,
@@ -59,12 +78,8 @@ function localReflection(payload) {
59
  return { ...fallbackReading, score: Math.max(35, Math.min(74, score)) };
60
  }
61
 
62
- function pruneSubmissions() {
63
- const expiry = Date.now() - 24 * 60 * 60 * 1000;
64
- for (const [id, submission] of submissions) {
65
- if (submission.createdAt < expiry) submissions.delete(id);
66
- }
67
- while (submissions.size > 200) submissions.delete(submissions.keys().next().value);
68
  }
69
 
70
  async function rateSincerity(payload) {
@@ -109,12 +124,26 @@ async function readBody(req) {
109
  }
110
 
111
  async function handleApi(req, res) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  if (req.method === 'POST' && req.url === '/api/owner-unlock') {
113
  try {
114
  const payload = await readBody(req);
115
  const expected = process.env.OWNER_VIEW_PASSWORD || '';
116
  const supplied = cleanString(payload.password, 200);
117
- const submission = submissions.get(cleanString(payload.submissionId, 100));
118
  const matches = expected && supplied && Buffer.byteLength(expected) === Buffer.byteLength(supplied) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
119
  if (!matches || !submission) {
120
  sendJson(res, 401, { error: 'That key does not open this room.' });
@@ -157,8 +186,16 @@ async function handleApi(req, res) {
157
  reading = localReflection(normalizedPayload);
158
  }
159
  const submissionId = crypto.randomUUID();
160
- submissions.set(submissionId, { ...normalizedPayload, reading, createdAt: Date.now() });
161
- pruneSubmissions();
 
 
 
 
 
 
 
 
162
  sendJson(res, 200, { sealed: true, submissionId });
163
  } catch (error) {
164
  console.error('[sincerity]', error.message);
@@ -169,7 +206,7 @@ async function handleApi(req, res) {
169
 
170
  function serveStatic(req, res) {
171
  const requestPath = new URL(req.url, 'http://localhost').pathname;
172
- const relative = requestPath === '/' ? 'index.html' : requestPath.replace(/^\//, '');
173
  const filePath = path.resolve(__dirname, isProduction ? 'dist' : 'public', relative);
174
  const basePath = path.resolve(__dirname, isProduction ? 'dist' : 'public');
175
  if (!filePath.startsWith(basePath)) {
@@ -177,7 +214,7 @@ function serveStatic(req, res) {
177
  }
178
  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
179
  const ext = path.extname(filePath);
180
- const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json' };
181
  res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
182
  fs.createReadStream(filePath).pipe(res);
183
  } else {
 
9
  const port = Number(process.env.PORT || 3000);
10
  const isProduction = process.env.NODE_ENV === 'production';
11
  const model = 'cohere/north-mini-code:free';
12
+ const storageDir = process.env.DATA_DIR || (fs.existsSync('/data') ? '/data' : path.join(__dirname, 'data'));
13
+ const submissionsFile = path.join(storageDir, 'submissions.json');
14
+
15
+ function loadSubmissions() {
16
+ try {
17
+ const parsed = JSON.parse(fs.readFileSync(submissionsFile, 'utf8'));
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ let submissions = loadSubmissions();
25
+
26
+ function persistSubmissions() {
27
+ fs.mkdirSync(storageDir, { recursive: true });
28
+ const tempFile = `${submissionsFile}.tmp`;
29
+ fs.writeFileSync(tempFile, JSON.stringify(submissions, null, 2));
30
+ fs.renameSync(tempFile, submissionsFile);
31
+ }
32
 
33
  const fallbackReading = {
34
  score: 58,
 
78
  return { ...fallbackReading, score: Math.max(35, Math.min(74, score)) };
79
  }
80
 
81
+ function findSubmission(id) {
82
+ return submissions.find((submission) => submission.id === id);
 
 
 
 
83
  }
84
 
85
  async function rateSincerity(payload) {
 
124
  }
125
 
126
  async function handleApi(req, res) {
127
+ if (req.method === 'GET' && req.url === '/api/owner/submissions') {
128
+ sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) });
129
+ return true;
130
+ }
131
+ if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
132
+ const id = cleanString(req.url.split('/').pop(), 100);
133
+ const submission = findSubmission(id);
134
+ if (!submission) {
135
+ sendJson(res, 404, { error: 'Submission not found.' });
136
+ return true;
137
+ }
138
+ sendJson(res, 200, { submission });
139
+ return true;
140
+ }
141
  if (req.method === 'POST' && req.url === '/api/owner-unlock') {
142
  try {
143
  const payload = await readBody(req);
144
  const expected = process.env.OWNER_VIEW_PASSWORD || '';
145
  const supplied = cleanString(payload.password, 200);
146
+ const submission = findSubmission(cleanString(payload.submissionId, 100));
147
  const matches = expected && supplied && Buffer.byteLength(expected) === Buffer.byteLength(supplied) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
148
  if (!matches || !submission) {
149
  sendJson(res, 401, { error: 'That key does not open this room.' });
 
186
  reading = localReflection(normalizedPayload);
187
  }
188
  const submissionId = crypto.randomUUID();
189
+ submissions.push({
190
+ id: submissionId,
191
+ ...normalizedPayload,
192
+ reading,
193
+ createdAt: Date.now(),
194
+ viewed: false,
195
+ decision: 'pending',
196
+ updatedAt: Date.now(),
197
+ });
198
+ persistSubmissions();
199
  sendJson(res, 200, { sealed: true, submissionId });
200
  } catch (error) {
201
  console.error('[sincerity]', error.message);
 
206
 
207
  function serveStatic(req, res) {
208
  const requestPath = new URL(req.url, 'http://localhost').pathname;
209
+ const relative = requestPath === '/' ? 'index.html' : requestPath === '/owner' ? 'owner.html' : requestPath.replace(/^\//, '');
210
  const filePath = path.resolve(__dirname, isProduction ? 'dist' : 'public', relative);
211
  const basePath = path.resolve(__dirname, isProduction ? 'dist' : 'public');
212
  if (!filePath.startsWith(basePath)) {
 
214
  }
215
  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
216
  const ext = path.extname(filePath);
217
+ const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json', '.mp3': 'audio/mpeg' };
218
  res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
219
  fs.createReadStream(filePath).pipe(res);
220
  } else {