Initial build: personal annual letter + podcast year-end email generator
- Monthly journal: 6 prompts per month, saved to localStorage - Year-end reflections: 6 closing prompts compiled into a letter - Podcast module: 9 prompts → ready-to-send listener email with preview tab - Home dashboard: month grid, progress bar, year-end task checklist - Letter view: formatted narrative with Copy and Download - Dockerfile + nginx.conf for containerized deployment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
c446546964
31 files changed
+3159
No files matched your search
+123
@@ -0,0 +1,123 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import type { View } from "./components/Sidebar";
|
||||
import { MonthlyForm, YearEndForm } from "./components/PromptForm";
|
||||
import { LetterView } from "./components/LetterView";
|
||||
import { Welcome } from "./components/Welcome";
|
||||
import { PodcastYearEndForm } from "./components/PodcastYearEnd";
|
||||
import { Home } from "./components/Home";
|
||||
import {
|
||||
load,
|
||||
save,
|
||||
getMonthlyEntry,
|
||||
upsertMonthlyEntry,
|
||||
getYearEndEntry,
|
||||
upsertYearEndEntry,
|
||||
getPodcastEntry,
|
||||
upsertPodcastEntry,
|
||||
} from "./utils/storage";
|
||||
import type { AppData } from "./types";
|
||||
|
||||
const now = new Date();
|
||||
const TODAY_YEAR = now.getFullYear();
|
||||
const TODAY_MONTH = now.getMonth();
|
||||
|
||||
export default function App() {
|
||||
const [data, setData] = useState<AppData>(load);
|
||||
const [view, setView] = useState<View>({ kind: "home" });
|
||||
|
||||
const persist = useCallback((next: AppData) => {
|
||||
setData(next);
|
||||
save(next);
|
||||
}, []);
|
||||
|
||||
function handleStart(name: string) {
|
||||
persist({ ...data, name });
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return (
|
||||
<Welcome
|
||||
currentMonth={TODAY_MONTH}
|
||||
currentYear={TODAY_YEAR}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Sidebar
|
||||
data={data}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
currentYear={TODAY_YEAR}
|
||||
currentMonth={TODAY_MONTH}
|
||||
/>
|
||||
<main className="main">
|
||||
{view.kind === "home" && (
|
||||
<Home
|
||||
data={data}
|
||||
currentYear={TODAY_YEAR}
|
||||
currentMonth={TODAY_MONTH}
|
||||
onNavigate={setView}
|
||||
/>
|
||||
)}
|
||||
{view.kind === "month" && (
|
||||
<MonthlyForm
|
||||
key={`${view.year}-${view.month}`}
|
||||
entry={getMonthlyEntry(data, view.year, view.month)}
|
||||
year={view.year}
|
||||
month={view.month}
|
||||
onSave={(answers) => {
|
||||
persist(
|
||||
upsertMonthlyEntry(data, {
|
||||
year: view.year,
|
||||
month: view.month,
|
||||
answers,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view.kind === "yearEnd" && (
|
||||
<YearEndForm
|
||||
key={view.year}
|
||||
entry={getYearEndEntry(data, view.year)}
|
||||
year={view.year}
|
||||
onSave={(answers) => {
|
||||
persist(
|
||||
upsertYearEndEntry(data, {
|
||||
year: view.year,
|
||||
answers,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view.kind === "letter" && (
|
||||
<LetterView data={data} year={view.year} />
|
||||
)}
|
||||
{view.kind === "podcast" && (
|
||||
<PodcastYearEndForm
|
||||
key={view.year}
|
||||
entry={getPodcastEntry(data, view.year)}
|
||||
year={view.year}
|
||||
onSave={(podcastName, answers) => {
|
||||
persist(
|
||||
upsertPodcastEntry(data, {
|
||||
year: view.year,
|
||||
podcastName,
|
||||
answers,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,173 @@
|
||||
import { MONTH_NAMES, MONTHLY_PROMPTS, PODCAST_PROMPTS, YEAR_END_PROMPTS } from "../types";
|
||||
import type { AppData } from "../types";
|
||||
import type { View } from "./Sidebar";
|
||||
|
||||
interface HomeProps {
|
||||
data: AppData;
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
onNavigate: (v: View) => void;
|
||||
}
|
||||
|
||||
export function Home({ data, currentYear, currentMonth, onNavigate }: HomeProps) {
|
||||
const monthEntries = data.monthlyEntries.filter((e) => e.year === currentYear);
|
||||
const yearEnd = data.yearEndEntries.find((e) => e.year === currentYear);
|
||||
const podcast = data.podcastEntries?.find((e) => e.year === currentYear);
|
||||
|
||||
const accessibleMonths = currentMonth + 1; // 0-indexed, months up to today
|
||||
const completedMonths = monthEntries.length;
|
||||
const pct = Math.round((completedMonths / 12) * 100);
|
||||
|
||||
const yearEndFilled = yearEnd
|
||||
? YEAR_END_PROMPTS.filter((p) => yearEnd.answers[p.id]?.trim()).length
|
||||
: 0;
|
||||
|
||||
const podcastFilled = podcast
|
||||
? PODCAST_PROMPTS.filter((p) => podcast.answers[p.id]?.trim()).length
|
||||
: 0;
|
||||
|
||||
const pendingMonths = MONTH_NAMES.slice(0, currentMonth + 1).filter(
|
||||
(_, i) => !monthEntries.some((e) => e.month === i)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<div className="home-header">
|
||||
<div>
|
||||
<h1 className="home-year">{currentYear}</h1>
|
||||
<p className="home-sub">Your year, in progress.</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => onNavigate({ kind: "month", year: currentYear, month: currentMonth })}
|
||||
>
|
||||
{monthEntries.some((e) => e.month === currentMonth)
|
||||
? `Edit ${MONTH_NAMES[currentMonth]}`
|
||||
: `Start ${MONTH_NAMES[currentMonth]}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="home-section">
|
||||
<div className="progress-row">
|
||||
<span className="section-label">Monthly entries</span>
|
||||
<span className="progress-count">{completedMonths} / 12 months</span>
|
||||
</div>
|
||||
<div className="progress-bar-track">
|
||||
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month grid */}
|
||||
<div className="month-grid">
|
||||
{MONTH_NAMES.map((name, idx) => {
|
||||
const done = monthEntries.some((e) => e.month === idx);
|
||||
const isCurrent = idx === currentMonth;
|
||||
const isFuture = idx > currentMonth;
|
||||
const entry = monthEntries.find((e) => e.month === idx);
|
||||
const answeredCount = entry
|
||||
? MONTHLY_PROMPTS.filter((p) => entry.answers[p.id]?.trim()).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
className={`month-card ${done ? "done" : ""} ${isCurrent ? "current" : ""} ${isFuture ? "future" : ""}`}
|
||||
disabled={isFuture}
|
||||
onClick={() => !isFuture && onNavigate({ kind: "month", year: currentYear, month: idx })}
|
||||
>
|
||||
<span className="month-card-name">{name.slice(0, 3)}</span>
|
||||
{done && (
|
||||
<span className="month-card-count">{answeredCount}/{MONTHLY_PROMPTS.length}</span>
|
||||
)}
|
||||
{!done && !isFuture && (
|
||||
<span className="month-card-pending">pending</span>
|
||||
)}
|
||||
{isFuture && <span className="month-card-future">—</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
<div className="home-section">
|
||||
<div className="section-label" style={{ marginBottom: 14 }}>Year-end tasks</div>
|
||||
<div className="checklist">
|
||||
<ChecklistItem
|
||||
label="Year-End Reflections"
|
||||
detail={yearEnd ? `${yearEndFilled}/${YEAR_END_PROMPTS.length} answered` : "Not started"}
|
||||
done={yearEndFilled === YEAR_END_PROMPTS.length}
|
||||
partial={yearEndFilled > 0 && yearEndFilled < YEAR_END_PROMPTS.length}
|
||||
onClick={() => onNavigate({ kind: "yearEnd", year: currentYear })}
|
||||
/>
|
||||
<ChecklistItem
|
||||
label="Podcast Year-End Email"
|
||||
detail={podcast ? `${podcastFilled}/${PODCAST_PROMPTS.length} answered` : "Not started"}
|
||||
done={podcastFilled === PODCAST_PROMPTS.length}
|
||||
partial={podcastFilled > 0 && podcastFilled < PODCAST_PROMPTS.length}
|
||||
onClick={() => onNavigate({ kind: "podcast", year: currentYear })}
|
||||
/>
|
||||
<ChecklistItem
|
||||
label="Read Your Letter"
|
||||
detail={completedMonths > 0 ? `${completedMonths} month${completedMonths !== 1 ? "s" : ""} compiled` : "Fill in some months first"}
|
||||
done={false}
|
||||
partial={completedMonths > 0}
|
||||
onClick={() => onNavigate({ kind: "letter", year: currentYear })}
|
||||
isLink
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What's missing */}
|
||||
{pendingMonths.length > 0 && (
|
||||
<div className="home-section">
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Still to fill in</div>
|
||||
<div className="missing-months">
|
||||
{pendingMonths.map((name, i) => {
|
||||
const monthIdx = MONTH_NAMES.indexOf(name);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className="missing-tag"
|
||||
onClick={() => onNavigate({ kind: "month", year: currentYear, month: monthIdx })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingMonths.length === 0 && completedMonths > 0 && (
|
||||
<div className="home-all-done">
|
||||
All months up to {MONTH_NAMES[currentMonth]} are filled in.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChecklistItemProps {
|
||||
label: string;
|
||||
detail: string;
|
||||
done: boolean;
|
||||
partial: boolean;
|
||||
onClick: () => void;
|
||||
isLink?: boolean;
|
||||
}
|
||||
|
||||
function ChecklistItem({ label, detail, done, partial, onClick, isLink }: ChecklistItemProps) {
|
||||
return (
|
||||
<button className={`checklist-item ${done ? "done" : partial ? "partial" : ""}`} onClick={onClick}>
|
||||
<span className={`check-icon ${done ? "done" : partial ? "partial" : ""}`}>
|
||||
{done ? "✓" : partial ? "◑" : "○"}
|
||||
</span>
|
||||
<span className="check-body">
|
||||
<span className="check-label">{label}</span>
|
||||
<span className="check-detail">{detail}</span>
|
||||
</span>
|
||||
<span className="check-arrow">{isLink ? "→" : done ? "" : "→"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { generateLetter } from "../utils/letterTemplate";
|
||||
import type { AppData } from "../types";
|
||||
|
||||
interface LetterViewProps {
|
||||
data: AppData;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function LetterView({ data, year }: LetterViewProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const letter = generateLetter(data, year);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(letter).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const blob = new Blob([letter], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${year}-annual-letter.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const rendered = renderMarkdown(letter);
|
||||
|
||||
return (
|
||||
<div className="letter-view">
|
||||
<div className="letter-toolbar">
|
||||
<h2>{year} Annual Letter</h2>
|
||||
<div className="toolbar-actions">
|
||||
<button className="btn-secondary" onClick={handleCopy}>
|
||||
{copied ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={handleDownload}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="letter-content"
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// Split on double newlines into paragraphs
|
||||
return text
|
||||
.split(/\n\n+/)
|
||||
.map((para) => {
|
||||
const inner = para
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
line = line.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
line = line.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||
return line;
|
||||
})
|
||||
.join("<br/>");
|
||||
return `<p>${inner}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PODCAST_PROMPTS } from "../types";
|
||||
import { generatePodcastEmail } from "../utils/podcastEmailTemplate";
|
||||
import type { PodcastYearEndEntry } from "../types";
|
||||
|
||||
interface PodcastFormProps {
|
||||
entry?: PodcastYearEndEntry;
|
||||
year: number;
|
||||
onSave: (podcastName: string, answers: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function PodcastYearEndForm({ entry, year, onSave }: PodcastFormProps) {
|
||||
const [podcastName, setPodcastName] = useState(entry?.podcastName ?? "");
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(
|
||||
entry?.answers ?? {}
|
||||
);
|
||||
const [view, setView] = useState<"form" | "email">("form");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPodcastName(entry?.podcastName ?? "");
|
||||
setAnswers(entry?.answers ?? {});
|
||||
setView("form");
|
||||
setSaved(false);
|
||||
}, [year, entry]);
|
||||
|
||||
function handleSave() {
|
||||
onSave(podcastName, answers);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
const filled = PODCAST_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
|
||||
const previewEntry: PodcastYearEndEntry = {
|
||||
year,
|
||||
podcastName,
|
||||
answers,
|
||||
};
|
||||
const email = generatePodcastEmail(previewEntry);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(email).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return text
|
||||
.split(/\n\n+/)
|
||||
.map((para) => {
|
||||
const inner = para
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"))
|
||||
.join("<br/>");
|
||||
return `<p>${inner}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prompt-form">
|
||||
<div className="form-header">
|
||||
<h2>Podcast Year-End Email — {year}</h2>
|
||||
{view === "form" && (
|
||||
<span className="progress-badge podcast">
|
||||
{filled}/{PODCAST_PROMPTS.length} answered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="form-intro">
|
||||
Answer a few questions about your podcast year. The app will compile them into a ready-to-send listener email.
|
||||
</p>
|
||||
|
||||
<div className="tab-bar">
|
||||
<button
|
||||
className={`tab ${view === "form" ? "active" : ""}`}
|
||||
onClick={() => setView("form")}
|
||||
>
|
||||
Prompts
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${view === "email" ? "active" : ""}`}
|
||||
onClick={() => setView("email")}
|
||||
>
|
||||
Preview Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === "form" && (
|
||||
<>
|
||||
<div className="prompts" style={{ marginTop: 24 }}>
|
||||
<div className="prompt-item">
|
||||
<label className="prompt-question">What's your podcast called?</label>
|
||||
<input
|
||||
type="text"
|
||||
className="prompt-textarea"
|
||||
style={{ height: 44, resize: "none" }}
|
||||
placeholder="Show name"
|
||||
value={podcastName}
|
||||
onChange={(e) => setPodcastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{PODCAST_PROMPTS.map((p) => (
|
||||
<div key={p.id} className="prompt-item">
|
||||
<label className="prompt-question">{p.question}</label>
|
||||
<textarea
|
||||
className="prompt-textarea"
|
||||
rows={p.id === "episodes" ? 1 : 3}
|
||||
placeholder={p.placeholder}
|
||||
value={answers[p.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button className="btn-primary" onClick={handleSave}>
|
||||
{saved ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "email" && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div className="letter-toolbar" style={{ marginBottom: 20 }}>
|
||||
<span style={{ fontSize: ".9rem", color: "var(--text-muted)", fontStyle: "italic" }}>
|
||||
Ready to copy into your email platform
|
||||
</span>
|
||||
<div className="toolbar-actions">
|
||||
<button className="btn-secondary" onClick={handleCopy}>
|
||||
{copied ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="letter-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(email) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { MonthlyEntry, YearEndEntry } from "../types";
|
||||
import { MONTHLY_PROMPTS, YEAR_END_PROMPTS, MONTH_NAMES } from "../types";
|
||||
|
||||
interface MonthlyFormProps {
|
||||
entry?: MonthlyEntry;
|
||||
year: number;
|
||||
month: number;
|
||||
onSave: (answers: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function MonthlyForm({ entry, year, month, onSave }: MonthlyFormProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(
|
||||
entry?.answers ?? {}
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers(entry?.answers ?? {});
|
||||
setSaved(false);
|
||||
}, [year, month, entry]);
|
||||
|
||||
function handleSave() {
|
||||
onSave(answers);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
const filled = MONTHLY_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
|
||||
|
||||
return (
|
||||
<div className="prompt-form">
|
||||
<div className="form-header">
|
||||
<h2>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</h2>
|
||||
<span className="progress-badge">
|
||||
{filled}/{MONTHLY_PROMPTS.length} answered
|
||||
</span>
|
||||
</div>
|
||||
<div className="prompts">
|
||||
{MONTHLY_PROMPTS.map((p) => (
|
||||
<div key={p.id} className="prompt-item">
|
||||
<label className="prompt-question">{p.question}</label>
|
||||
<textarea
|
||||
className="prompt-textarea"
|
||||
rows={3}
|
||||
placeholder={p.placeholder}
|
||||
value={answers[p.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button className="btn-primary" onClick={handleSave}>
|
||||
{saved ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface YearEndFormProps {
|
||||
entry?: YearEndEntry;
|
||||
year: number;
|
||||
onSave: (answers: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function YearEndForm({ entry, year, onSave }: YearEndFormProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(
|
||||
entry?.answers ?? {}
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers(entry?.answers ?? {});
|
||||
setSaved(false);
|
||||
}, [year, entry]);
|
||||
|
||||
function handleSave() {
|
||||
onSave(answers);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
const filled = YEAR_END_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
|
||||
|
||||
return (
|
||||
<div className="prompt-form year-end-form">
|
||||
<div className="form-header">
|
||||
<h2>Year-End Reflection — {year}</h2>
|
||||
<span className="progress-badge year-end">
|
||||
{filled}/{YEAR_END_PROMPTS.length} answered
|
||||
</span>
|
||||
</div>
|
||||
<p className="form-intro">
|
||||
The prompts that close the year and anchor the letter.
|
||||
</p>
|
||||
<div className="prompts">
|
||||
{YEAR_END_PROMPTS.map((p) => (
|
||||
<div key={p.id} className="prompt-item">
|
||||
<label className="prompt-question">{p.question}</label>
|
||||
<textarea
|
||||
className="prompt-textarea"
|
||||
rows={p.id === "word" ? 1 : 3}
|
||||
placeholder={p.placeholder}
|
||||
value={answers[p.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button className="btn-primary" onClick={handleSave}>
|
||||
{saved ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { MONTH_NAMES } from "../types";
|
||||
import type { AppData } from "../types";
|
||||
|
||||
export type View =
|
||||
| { kind: "home" }
|
||||
| { kind: "month"; year: number; month: number }
|
||||
| { kind: "yearEnd"; year: number }
|
||||
| { kind: "letter"; year: number }
|
||||
| { kind: "podcast"; year: number };
|
||||
|
||||
interface SidebarProps {
|
||||
data: AppData;
|
||||
view: View;
|
||||
onViewChange: (v: View) => void;
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
data,
|
||||
view,
|
||||
onViewChange,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
}: SidebarProps) {
|
||||
const years = getRelevantYears(currentYear);
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<button
|
||||
className={`sidebar-brand ${view.kind === "home" ? "sidebar-brand-active" : ""}`}
|
||||
onClick={() => onViewChange({ kind: "home" })}
|
||||
style={{ cursor: "pointer", background: "none", border: "none", width: "100%", textAlign: "left" }}
|
||||
>
|
||||
<span className="brand-icon">✉</span>
|
||||
<span className="brand-name">Annual Letter</span>
|
||||
</button>
|
||||
|
||||
{years.map((year) => {
|
||||
const monthEntries = data.monthlyEntries.filter((e) => e.year === year);
|
||||
const yearEndEntry = data.yearEndEntries.find((e) => e.year === year);
|
||||
const podcastEntry = data.podcastEntries?.find((e) => e.year === year);
|
||||
|
||||
return (
|
||||
<div key={year} className="sidebar-year-section">
|
||||
<div className="sidebar-year-label">{year}</div>
|
||||
|
||||
<ul className="sidebar-months">
|
||||
{MONTH_NAMES.map((name, idx) => {
|
||||
const isFuture = year === currentYear && idx > currentMonth;
|
||||
if (isFuture) return null;
|
||||
|
||||
const hasEntry = monthEntries.some((e) => e.month === idx);
|
||||
const isActive =
|
||||
view.kind === "month" &&
|
||||
view.year === year &&
|
||||
view.month === idx;
|
||||
const isCurrent = year === currentYear && idx === currentMonth;
|
||||
|
||||
return (
|
||||
<li key={idx}>
|
||||
<button
|
||||
className={`sidebar-item ${isActive ? "active" : ""} ${isCurrent ? "current" : ""}`}
|
||||
onClick={() =>
|
||||
onViewChange({ kind: "month", year, month: idx })
|
||||
}
|
||||
>
|
||||
<span className="sidebar-item-name">{name}</span>
|
||||
{hasEntry && <span className="dot" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li>
|
||||
<button
|
||||
className={`sidebar-item sidebar-yearend ${view.kind === "yearEnd" && view.year === year ? "active" : ""}`}
|
||||
onClick={() => onViewChange({ kind: "yearEnd", year })}
|
||||
>
|
||||
<span className="sidebar-item-name">Year-End</span>
|
||||
{yearEndEntry && <span className="dot" />}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
className={`sidebar-item sidebar-letter ${view.kind === "letter" && view.year === year ? "active" : ""}`}
|
||||
onClick={() => onViewChange({ kind: "letter", year })}
|
||||
>
|
||||
<span className="sidebar-item-name">📄 Read Letter</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="sidebar-section-header">Podcast</div>
|
||||
<li>
|
||||
<button
|
||||
className={`sidebar-item sidebar-podcast ${view.kind === "podcast" && view.year === year ? "active" : ""}`}
|
||||
onClick={() => onViewChange({ kind: "podcast", year })}
|
||||
>
|
||||
<span className="sidebar-item-name">🎙 Year-End Email</span>
|
||||
{podcastEntry && <span className="dot" />}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function getRelevantYears(currentYear: number): number[] {
|
||||
const years = [currentYear];
|
||||
for (let y = currentYear - 1; y >= currentYear - 3; y--) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { MONTH_NAMES } from "../types";
|
||||
|
||||
interface WelcomeProps {
|
||||
currentMonth: number;
|
||||
currentYear: number;
|
||||
onStart: (name: string) => void;
|
||||
}
|
||||
|
||||
export function Welcome({ currentMonth, currentYear, onStart }: WelcomeProps) {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
return (
|
||||
<div className="welcome">
|
||||
<div className="welcome-inner">
|
||||
<span className="welcome-icon">✉</span>
|
||||
<h1>Your Annual Letter</h1>
|
||||
<p className="welcome-tagline">
|
||||
Answer a handful of prompts each month. At year's end, they become a
|
||||
letter you'll want to keep.
|
||||
</p>
|
||||
<div className="welcome-how">
|
||||
<div className="how-step">
|
||||
<span className="how-num">01</span>
|
||||
<span>Answer 6 prompts each month — takes about 10 minutes</span>
|
||||
</div>
|
||||
<div className="how-step">
|
||||
<span className="how-num">02</span>
|
||||
<span>Add year-end reflections in December</span>
|
||||
</div>
|
||||
<div className="how-step">
|
||||
<span className="how-num">03</span>
|
||||
<span>Read (and keep) your compiled letter</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="welcome-form">
|
||||
<label className="welcome-label">What should the letter call you?</label>
|
||||
<input
|
||||
type="text"
|
||||
className="welcome-input"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && name.trim() && onStart(name.trim())}
|
||||
/>
|
||||
<button
|
||||
className="btn-primary btn-large"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => onStart(name.trim())}
|
||||
>
|
||||
Start — {MONTH_NAMES[currentMonth]} {currentYear}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
/* ─── Reset ─────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 16px; }
|
||||
body { font-family: "Georgia", serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
/* ─── Tokens ─────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #faf9f7;
|
||||
--bg-sidebar: #1c1917;
|
||||
--bg-card: #ffffff;
|
||||
--text: #1c1917;
|
||||
--text-muted: #78716c;
|
||||
--text-sidebar: #d6d3d1;
|
||||
--text-sidebar-muted: #78716c;
|
||||
--accent: #b45309;
|
||||
--accent-light: #fef3c7;
|
||||
--accent-dark: #92400e;
|
||||
--border: #e7e5e4;
|
||||
--border-sidebar: #292524;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
/* ─── Layout ─────────────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
padding: 24px 0 40px;
|
||||
overflow-y: auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 20px 24px;
|
||||
border-bottom: 1px solid var(--border-sidebar);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.brand-icon { font-size: 1.2rem; }
|
||||
.brand-name { font-size: .95rem; font-weight: 600; color: var(--text-sidebar); letter-spacing: .02em; }
|
||||
|
||||
.sidebar-year-section { margin-bottom: 8px; }
|
||||
.sidebar-year-label {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-sidebar-muted);
|
||||
padding: 0 20px 6px;
|
||||
}
|
||||
|
||||
.sidebar-months { list-style: none; }
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-sidebar-muted);
|
||||
font-size: .88rem;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
border-radius: 0;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.sidebar-item:hover { background: rgba(255,255,255,.06); color: var(--text-sidebar); }
|
||||
.sidebar-item.active { background: rgba(255,255,255,.1); color: #fff; }
|
||||
.sidebar-item.current .sidebar-item-name::after { content: " •"; color: var(--accent); }
|
||||
|
||||
.sidebar-yearend, .sidebar-letter, .sidebar-podcast { margin-top: 2px; }
|
||||
.sidebar-letter .sidebar-item-name, .sidebar-podcast .sidebar-item-name {
|
||||
font-style: italic;
|
||||
}
|
||||
.sidebar-podcast { border-top: 1px solid var(--border-sidebar); margin-top: 8px; padding-top: 4px; }
|
||||
|
||||
.sidebar-section-header {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-sidebar-muted);
|
||||
padding: 12px 20px 6px;
|
||||
border-top: 1px solid var(--border-sidebar);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Main ───────────────────────────────────────────────────────────────── */
|
||||
.main {
|
||||
padding: 48px 56px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
/* ─── Prompt form ────────────────────────────────────────────────────────── */
|
||||
.prompt-form { }
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-header h2 { font-size: 1.7rem; font-weight: 700; color: var(--text); }
|
||||
|
||||
.progress-badge {
|
||||
font-size: .75rem;
|
||||
font-family: "ui-monospace", monospace;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-dark);
|
||||
border-radius: 99px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.progress-badge.year-end { background: #f0fdf4; color: #166534; }
|
||||
.progress-badge.podcast { background: #eff6ff; color: #1e40af; }
|
||||
|
||||
.form-intro {
|
||||
font-size: .9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 32px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prompts { display: flex; flex-direction: column; gap: 28px; margin-top: 32px; }
|
||||
|
||||
.prompt-item { display: flex; flex-direction: column; gap: 8px; }
|
||||
.prompt-question {
|
||||
font-size: .95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prompt-textarea, .prompt-input {
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: .93rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-card);
|
||||
resize: vertical;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prompt-input { resize: none; height: 40px; }
|
||||
.prompt-textarea:focus, .prompt-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(180,83,9,.12);
|
||||
}
|
||||
.prompt-textarea::placeholder, .prompt-input::placeholder { color: #c4bdb8; }
|
||||
|
||||
.form-footer { margin-top: 40px; }
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 24px;
|
||||
font-size: .93rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dark); }
|
||||
.btn-primary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-large { padding: 14px 32px; font-size: 1rem; }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 18px;
|
||||
font-size: .88rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--text-muted); color: var(--text); }
|
||||
|
||||
/* ─── Letter view ────────────────────────────────────────────────────────── */
|
||||
.letter-view { }
|
||||
.letter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.letter-toolbar h2 { font-size: 1.5rem; font-weight: 700; }
|
||||
.toolbar-actions { display: flex; gap: 10px; }
|
||||
|
||||
.letter-content {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.85;
|
||||
color: var(--text);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 48px;
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 660px;
|
||||
}
|
||||
.letter-content p {
|
||||
margin: 0 0 1.2em;
|
||||
}
|
||||
.letter-content p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ─── Welcome ────────────────────────────────────────────────────────────── */
|
||||
.welcome {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.welcome-inner {
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.welcome-icon { font-size: 2.4rem; display: block; margin-bottom: 16px; }
|
||||
.welcome-inner h1 { font-size: 2rem; font-weight: 700; margin-bottom: 12px; }
|
||||
.welcome-tagline { font-size: 1.05rem; color: var(--text-muted); line-height: 1.6; margin-bottom: 36px; }
|
||||
|
||||
.welcome-how {
|
||||
text-align: left;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.how-step { display: flex; gap: 14px; align-items: flex-start; font-size: .93rem; }
|
||||
.how-num { font-family: monospace; font-size: .78rem; color: var(--accent); font-weight: 700; padding-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.welcome-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.welcome-label { font-size: .88rem; font-weight: 600; text-align: left; }
|
||||
.welcome-input {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.welcome-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ─── Sidebar brand active ───────────────────────────────────────────────── */
|
||||
.sidebar-brand-active { background: rgba(255,255,255,.1) !important; border-radius: 0; }
|
||||
|
||||
/* ─── Home page ──────────────────────────────────────────────────────────── */
|
||||
.home { max-width: 680px; }
|
||||
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
gap: 20px;
|
||||
}
|
||||
.home-year { font-size: 2.8rem; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
|
||||
.home-sub { font-size: 1rem; color: var(--text-muted); margin-top: 4px; font-style: italic; }
|
||||
|
||||
.home-section { margin-bottom: 36px; }
|
||||
.section-label { font-size: .72rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); }
|
||||
|
||||
.progress-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
|
||||
.progress-count { font-size: .82rem; font-family: monospace; color: var(--text-muted); }
|
||||
.progress-bar-track { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; }
|
||||
.progress-bar-fill { height: 100%; background: var(--accent); border-radius: 99px; transition: width .4s ease; }
|
||||
|
||||
/* Month grid */
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.month-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 14px 8px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.month-card:hover:not(:disabled) { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(180,83,9,.1); }
|
||||
.month-card.done { border-color: #a3cfb8; background: #f0fdf4; }
|
||||
.month-card.current { border-color: var(--accent); }
|
||||
.month-card.future { opacity: .35; cursor: default; }
|
||||
.month-card-name { font-size: .85rem; font-weight: 600; color: var(--text); }
|
||||
.month-card-count { font-size: .7rem; font-family: monospace; color: #16a34a; }
|
||||
.month-card-pending { font-size: .68rem; color: var(--text-muted); font-style: italic; }
|
||||
.month-card-future { font-size: .7rem; color: #ccc; }
|
||||
|
||||
/* Checklist */
|
||||
.checklist { display: flex; flex-direction: column; gap: 8px; }
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: border-color .15s;
|
||||
width: 100%;
|
||||
}
|
||||
.checklist-item:hover { border-color: var(--accent); }
|
||||
.checklist-item.done { border-color: #a3cfb8; background: #f0fdf4; }
|
||||
.checklist-item.partial { border-color: #fbbf24; }
|
||||
|
||||
.check-icon { font-size: 1.1rem; flex-shrink: 0; width: 22px; text-align: center; }
|
||||
.check-icon.done { color: #16a34a; }
|
||||
.check-icon.partial { color: #d97706; }
|
||||
|
||||
.check-body { display: flex; flex-direction: column; flex: 1; gap: 2px; }
|
||||
.check-label { font-size: .92rem; font-weight: 600; color: var(--text); }
|
||||
.check-detail { font-size: .78rem; color: var(--text-muted); }
|
||||
|
||||
.check-arrow { font-size: .85rem; color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
/* Missing months */
|
||||
.missing-months { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.missing-tag {
|
||||
padding: 6px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 99px;
|
||||
background: var(--bg-card);
|
||||
font-size: .83rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.missing-tag:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.home-all-done {
|
||||
font-size: .9rem;
|
||||
color: #16a34a;
|
||||
font-style: italic;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ─── Tabs ───────────────────────────────────────────────────────────────── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
padding: 8px 20px;
|
||||
font-size: .9rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
export interface MonthlyEntry {
|
||||
year: number;
|
||||
month: number; // 0-indexed
|
||||
answers: Record<string, string>;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface YearEndEntry {
|
||||
year: number;
|
||||
answers: Record<string, string>;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AppData {
|
||||
name: string;
|
||||
monthlyEntries: MonthlyEntry[];
|
||||
yearEndEntries: YearEndEntry[];
|
||||
podcastEntries: PodcastYearEndEntry[];
|
||||
}
|
||||
|
||||
export const MONTHLY_PROMPTS: { id: string; question: string; placeholder: string }[] = [
|
||||
{
|
||||
id: "win",
|
||||
question: "What was your biggest win or achievement this month?",
|
||||
placeholder: "A project wrapped up, a goal reached, a moment of pride…",
|
||||
},
|
||||
{
|
||||
id: "challenge",
|
||||
question: "What challenged or surprised you most?",
|
||||
placeholder: "Something harder than expected, or that caught you off guard…",
|
||||
},
|
||||
{
|
||||
id: "learned",
|
||||
question: "What did you learn — about yourself, others, or the world?",
|
||||
placeholder: "A skill, an insight, something that shifted your perspective…",
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
question: "What one moment do you most want to remember?",
|
||||
placeholder: "A conversation, a place, a feeling you don't want to forget…",
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
question: "Who showed up for you, or who did you show up for?",
|
||||
placeholder: "A friend, colleague, stranger — a connection that mattered…",
|
||||
},
|
||||
{
|
||||
id: "forward",
|
||||
question: "What are you carrying into next month?",
|
||||
placeholder: "An intention, a question, something left unfinished…",
|
||||
},
|
||||
];
|
||||
|
||||
export const YEAR_END_PROMPTS: { id: string; question: string; placeholder: string }[] = [
|
||||
{
|
||||
id: "word",
|
||||
question: "What single word best captures your year?",
|
||||
placeholder: "Growth, chaos, turning, arrival…",
|
||||
},
|
||||
{
|
||||
id: "change",
|
||||
question: "What is the most important thing that changed this year?",
|
||||
placeholder: "In your life, your work, your relationships, yourself…",
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
question: "What did this year teach you about who you are?",
|
||||
placeholder: "A strength discovered, a limit found, a value clarified…",
|
||||
},
|
||||
{
|
||||
id: "gratitude",
|
||||
question: "What are you most grateful for from this year?",
|
||||
placeholder: "A person, an opportunity, a difficulty that made you stronger…",
|
||||
},
|
||||
{
|
||||
id: "release",
|
||||
question: "What are you ready to leave behind?",
|
||||
placeholder: "A habit, a belief, a version of yourself you've outgrown…",
|
||||
},
|
||||
{
|
||||
id: "carry",
|
||||
question: "What are you carrying forward into next year?",
|
||||
placeholder: "A goal, a promise to yourself, something that matters deeply…",
|
||||
},
|
||||
];
|
||||
|
||||
export interface PodcastYearEndEntry {
|
||||
year: number;
|
||||
podcastName: string;
|
||||
answers: Record<string, string>;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export const PODCAST_PROMPTS: { id: string; question: string; placeholder: string }[] = [
|
||||
{
|
||||
id: "episodes",
|
||||
question: "How many episodes did you publish this year?",
|
||||
placeholder: "52, 24, 10…",
|
||||
},
|
||||
{
|
||||
id: "favorite",
|
||||
question: "Which episode are you most proud of, and why?",
|
||||
placeholder: "Ep. 47 — it finally said what I'd been trying to say for months…",
|
||||
},
|
||||
{
|
||||
id: "top",
|
||||
question: "Which episode resonated most with listeners?",
|
||||
placeholder: "Most downloads, most replies, most shares…",
|
||||
},
|
||||
{
|
||||
id: "guest",
|
||||
question: "Who was your most memorable guest or conversation?",
|
||||
placeholder: "A guest who surprised you, challenged you, or moved you…",
|
||||
},
|
||||
{
|
||||
id: "growth",
|
||||
question: "How did the show grow or change this year?",
|
||||
placeholder: "New format, new audience, new confidence, new numbers…",
|
||||
},
|
||||
{
|
||||
id: "behind",
|
||||
question: "What happened behind the mic that listeners never heard?",
|
||||
placeholder: "A rough patch, a pivot, a decision made quietly…",
|
||||
},
|
||||
{
|
||||
id: "lesson",
|
||||
question: "What did podcasting teach you this year?",
|
||||
placeholder: "About storytelling, about yourself, about your audience…",
|
||||
},
|
||||
{
|
||||
id: "gratitude",
|
||||
question: "What do you most want to say to your listeners?",
|
||||
placeholder: "The thing you mean but don't always say out loud…",
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
question: "What's the one thing you want to do differently or more of next year?",
|
||||
placeholder: "A format change, a topic, a frequency, a commitment…",
|
||||
},
|
||||
];
|
||||
|
||||
export const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MONTH_NAMES } from "../types";
|
||||
import type { AppData } from "../types";
|
||||
|
||||
export function generateLetter(data: AppData, year: number): string {
|
||||
const name = data.name || "Friend";
|
||||
const entries = data.monthlyEntries
|
||||
.filter((e) => e.year === year)
|
||||
.sort((a, b) => a.month - b.month);
|
||||
const yearEnd = data.yearEndEntries.find((e) => e.year === year);
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
let letter = `${year} Annual Letter\n`;
|
||||
letter += `Written ${dateStr}\n\n`;
|
||||
letter += `Dear ${name},\n\n`;
|
||||
|
||||
if (yearEnd?.answers.word) {
|
||||
letter += `If ${year} had a single word, it would be: *${yearEnd.answers.word}*.\n\n`;
|
||||
}
|
||||
|
||||
if (entries.length === 0 && !yearEnd) {
|
||||
letter +=
|
||||
"This letter is still being written. Return here at the end of the year to read it.\n";
|
||||
return letter;
|
||||
}
|
||||
|
||||
// Monthly narrative
|
||||
if (entries.length > 0) {
|
||||
letter += `Here is what the months held:\n\n`;
|
||||
for (const entry of entries) {
|
||||
const monthName = MONTH_NAMES[entry.month];
|
||||
letter += `**${monthName}**\n`;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (entry.answers.win)
|
||||
parts.push(`The month's bright spot: ${entry.answers.win}.`);
|
||||
if (entry.answers.challenge)
|
||||
parts.push(`You were tested by: ${entry.answers.challenge}.`);
|
||||
if (entry.answers.learned)
|
||||
parts.push(`You learned: ${entry.answers.learned}.`);
|
||||
if (entry.answers.memory)
|
||||
parts.push(`The moment to hold onto: ${entry.answers.memory}.`);
|
||||
if (entry.answers.people)
|
||||
parts.push(`The people in the picture: ${entry.answers.people}.`);
|
||||
if (entry.answers.forward)
|
||||
parts.push(`Carrying forward: ${entry.answers.forward}.`);
|
||||
|
||||
letter += parts.join("\n") + "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Year-end reflections
|
||||
if (yearEnd) {
|
||||
letter += `**Looking back at the full year:**\n\n`;
|
||||
if (yearEnd.answers.change)
|
||||
letter += `The most important thing that changed: ${yearEnd.answers.change}\n\n`;
|
||||
if (yearEnd.answers.identity)
|
||||
letter += `What the year taught you about yourself: ${yearEnd.answers.identity}\n\n`;
|
||||
if (yearEnd.answers.gratitude)
|
||||
letter += `What you were most grateful for: ${yearEnd.answers.gratitude}\n\n`;
|
||||
if (yearEnd.answers.release)
|
||||
letter += `What you're ready to leave behind: ${yearEnd.answers.release}\n\n`;
|
||||
if (yearEnd.answers.carry)
|
||||
letter += `What you're carrying forward: ${yearEnd.answers.carry}\n\n`;
|
||||
}
|
||||
|
||||
const completedMonths = entries.length;
|
||||
if (completedMonths < 12) {
|
||||
const remaining = 12 - completedMonths;
|
||||
letter += `*${remaining} month${remaining !== 1 ? "s" : ""} of this year are still being written.*\n\n`;
|
||||
}
|
||||
|
||||
letter += `With love,\n${name} — ${year}`;
|
||||
return letter;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PodcastYearEndEntry } from "../types";
|
||||
|
||||
export function generatePodcastEmail(entry: PodcastYearEndEntry): string {
|
||||
const { answers, podcastName, year } = entry;
|
||||
const show = podcastName || "the show";
|
||||
|
||||
let email = `Subject: Looking back on ${year} — thank you\n\n`;
|
||||
email += `Hey everyone,\n\n`;
|
||||
|
||||
// Opening
|
||||
if (answers.episodes) {
|
||||
email += `${year} was a big year for ${show}. We published ${answers.episodes} episode${Number(answers.episodes) !== 1 ? "s" : ""} together — `;
|
||||
email += `and I've been sitting with what that means.\n\n`;
|
||||
} else {
|
||||
email += `As ${year} winds down, I wanted to take a moment to look back — with you.\n\n`;
|
||||
}
|
||||
|
||||
// Favorite episode
|
||||
if (answers.favorite) {
|
||||
email += `The episode I'm most proud of this year: ${answers.favorite}\n\n`;
|
||||
}
|
||||
|
||||
// Top episode
|
||||
if (answers.top) {
|
||||
email += `And the one that resonated most with all of you: ${answers.top}\n\n`;
|
||||
}
|
||||
|
||||
// Guest
|
||||
if (answers.guest) {
|
||||
email += `My most memorable conversation: ${answers.guest}\n\n`;
|
||||
}
|
||||
|
||||
// Growth / behind the scenes
|
||||
if (answers.growth || answers.behind) {
|
||||
email += `**Behind the scenes:**\n\n`;
|
||||
if (answers.growth) email += `${answers.growth}\n\n`;
|
||||
if (answers.behind) email += `${answers.behind}\n\n`;
|
||||
}
|
||||
|
||||
// Lesson
|
||||
if (answers.lesson) {
|
||||
email += `**What this year taught me:**\n\n${answers.lesson}\n\n`;
|
||||
}
|
||||
|
||||
// Looking ahead
|
||||
if (answers.next) {
|
||||
email += `**Looking ahead to ${year + 1}:**\n\n${answers.next}\n\n`;
|
||||
}
|
||||
|
||||
// Gratitude — the close
|
||||
if (answers.gratitude) {
|
||||
email += `${answers.gratitude}\n\n`;
|
||||
} else {
|
||||
email += `Thank you for listening, for sharing, for reaching out. You're the reason this keeps going.\n\n`;
|
||||
}
|
||||
|
||||
email += `Here's to ${year + 1},\n\n`;
|
||||
email += `[Your name]\n`;
|
||||
email += `${show}`;
|
||||
|
||||
return email;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AppData, MonthlyEntry, YearEndEntry, PodcastYearEndEntry } from "../types";
|
||||
|
||||
const KEY = "annual-letter-data";
|
||||
|
||||
const defaults: AppData = {
|
||||
name: "",
|
||||
monthlyEntries: [],
|
||||
yearEndEntries: [],
|
||||
podcastEntries: [],
|
||||
};
|
||||
|
||||
export function load(): AppData {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...defaults };
|
||||
return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...defaults };
|
||||
}
|
||||
}
|
||||
|
||||
export function save(data: AppData): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function getMonthlyEntry(
|
||||
data: AppData,
|
||||
year: number,
|
||||
month: number
|
||||
): MonthlyEntry | undefined {
|
||||
return data.monthlyEntries.find((e) => e.year === year && e.month === month);
|
||||
}
|
||||
|
||||
export function upsertMonthlyEntry(
|
||||
data: AppData,
|
||||
entry: MonthlyEntry
|
||||
): AppData {
|
||||
const idx = data.monthlyEntries.findIndex(
|
||||
(e) => e.year === entry.year && e.month === entry.month
|
||||
);
|
||||
const entries =
|
||||
idx >= 0
|
||||
? data.monthlyEntries.map((e, i) => (i === idx ? entry : e))
|
||||
: [...data.monthlyEntries, entry];
|
||||
return { ...data, monthlyEntries: entries };
|
||||
}
|
||||
|
||||
export function getYearEndEntry(
|
||||
data: AppData,
|
||||
year: number
|
||||
): YearEndEntry | undefined {
|
||||
return data.yearEndEntries.find((e) => e.year === year);
|
||||
}
|
||||
|
||||
export function upsertYearEndEntry(
|
||||
data: AppData,
|
||||
entry: YearEndEntry
|
||||
): AppData {
|
||||
const idx = data.yearEndEntries.findIndex((e) => e.year === entry.year);
|
||||
const entries =
|
||||
idx >= 0
|
||||
? data.yearEndEntries.map((e, i) => (i === idx ? entry : e))
|
||||
: [...data.yearEndEntries, entry];
|
||||
return { ...data, yearEndEntries: entries };
|
||||
}
|
||||
|
||||
export function getPodcastEntry(
|
||||
data: AppData,
|
||||
year: number
|
||||
): PodcastYearEndEntry | undefined {
|
||||
return data.podcastEntries.find((e) => e.year === year);
|
||||
}
|
||||
|
||||
export function upsertPodcastEntry(
|
||||
data: AppData,
|
||||
entry: PodcastYearEndEntry
|
||||
): AppData {
|
||||
const idx = data.podcastEntries.findIndex((e) => e.year === entry.year);
|
||||
const entries =
|
||||
idx >= 0
|
||||
? data.podcastEntries.map((e, i) => (i === idx ? entry : e))
|
||||
: [...data.podcastEntries, entry];
|
||||
return { ...data, podcastEntries: entries };
|
||||
}
|
||||
Reference in new issue
Block a user