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
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "annual-letter",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5173"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
*.md
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# SPA fallback — serve index.html for all routes
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Annual Letter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
Generated
+1327
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "letter",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in new issue
Block a user