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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 13:06:14 -04:00
commit c446546964
31 files changed
+3159

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "annual-letter",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5173"],
"port": 5173
}
]
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.claude
*.md
+24
View File
@@ -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?
+8
View File
@@ -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
View File
@@ -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;"]
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1327
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
}
}
+1
View File
@@ -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

+24
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+173
View File
@@ -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>
);
}
+70
View File
@@ -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("");
}
+148
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+118
View File
@@ -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;
}
+57
View File
@@ -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
View File
@@ -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; }
+10
View File
@@ -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
View File
@@ -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",
];
+80
View File
@@ -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;
}
+62
View File
@@ -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;
}
+84
View File
@@ -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 };
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})