First commit

This commit is contained in:
Greg 2025-09-01 23:06:39 +02:00
commit 23eb7d5125
16 changed files with 850 additions and 0 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
Dockerfile
.dockerignore
dist
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.DS_Store

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build stage
FROM node:18-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built app from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

131
README.md Normal file
View File

@ -0,0 +1,131 @@
# Year in Pixels - Mood Tracker
A React-based web application for tracking daily moods throughout the year using a visual "Year in Pixels" format. Track your mood with a simple Red-Amber-Green (RAG) system and visualize your year at a glance.
## Features
### 🎨 Visual Mood Tracking
- **Year in Pixels Grid**: 12 columns (months) × 31 rows (days) visual layout
- **RAG System**: Simple 3-color mood tracking (Red, Amber, Green)
- **Interactive Cells**: Click to cycle through moods or use paint mode for bulk editing
- **Today Highlight**: Current date is highlighted when viewing the current year
### ✨ User Experience
- **Drag Painting**: Select a color and drag across multiple days for quick entry
- **Responsive Design**: Works on desktop, tablet, and mobile devices
- **Dark Mode Support**: Automatic dark/light theme adaptation
- **Print Friendly**: Built-in print functionality for physical copies
### 💾 Data Management
- **Local Storage**: All data persists locally in your browser
- **Export Options**: Download your data as JSON or CSV
- **Import Support**: Upload previously exported JSON files
- **Year Navigation**: Switch between different years with preserved data
### ⚙️ Customization
- **Toggle Day Numbers**: Show/hide day numbers in cells
- **Compact Mode**: Reduced spacing for smaller displays
- **Show/Hide Legend**: Toggle mood color legend display
- **Year Reset**: Clear all entries for the current year
## Quick Start
### Local Development
1. **Clone and Install**
```bash
git clone <repository-url>
cd mood_tracker
npm install
```
2. **Start Development Server**
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser.
3. **Build for Production**
```bash
npm run build
npm run preview
```
### Docker Deployment
**Using Docker Compose (Recommended)**
```bash
docker-compose up -d
```
Access the app at [http://localhost:8080](http://localhost:8080)
**Using Docker directly**
```bash
docker build -t mood-tracker .
docker run -p 8080:80 mood-tracker
```
## How to Use
1. **Set Your Year**: Use the year selector to choose the year you want to track
2. **Track Your Mood**: Click on any day to cycle through moods (Empty → Green → Amber → Red → Empty)
3. **Paint Mode**: Select a color from the paint palette and drag across multiple days for quick entry
4. **View Stats**: See your mood summary (count of Green, Amber, Red days) at the top
5. **Export Data**: Save your progress as JSON or CSV files
6. **Import Data**: Upload previously exported JSON files to restore data
### Mood Colors
- **Green**: Good/positive mood days
- **Amber**: Neutral/okay mood days
- **Red**: Difficult/negative mood days
- **Gray**: No entry/untracked days
## Technology Stack
- **Frontend**: React 18 with TypeScript
- **Styling**: Tailwind CSS
- **Build Tool**: Vite
- **Data Storage**: Browser localStorage
- **Deployment**: Docker with Nginx
## Browser Compatibility
- Modern browsers with ES2020+ support
- Chrome/Edge 80+, Firefox 72+, Safari 13.1+
- Mobile browsers (iOS Safari, Chrome Mobile)
## Data Privacy
- **100% Local**: All mood data is stored locally in your browser
- **No Server**: No data is sent to external servers
- **Export Control**: You control your data with export/import features
- **No Analytics**: No tracking or analytics included
## File Structure
```
mood_tracker/
├── src/
│ ├── App.tsx # Main app component
│ ├── YearInPixelsMoodTracker.tsx # Core mood tracker component
│ ├── main.tsx # React app entry point
│ └── index.css # Global styles
├── public/ # Static assets
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose configuration
├── nginx.conf # Nginx configuration
├── package.json # Dependencies and scripts
└── vite.config.ts # Vite build configuration
```
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is open source and available under the [MIT License](LICENSE).

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
version: '3.8'
services:
mood-tracker:
build: .
ports:
- "8080:80"
restart: unless-stopped
environment:
- NODE_ENV=production

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mood Tracker - Year in Pixels</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
nginx.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# Handle client routing, return all requests to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Disable access to hidden files
location ~ /\. {
deny all;
}
}

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "mood-tracker",
"version": "1.0.0",
"description": "Year in Pixels Mood Tracker - A React app for tracking daily moods",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.4.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"type": "module"
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

7
src/App.tsx Normal file
View File

@ -0,0 +1,7 @@
import YearInPixelsMoodTracker from './YearInPixelsMoodTracker'
function App() {
return <YearInPixelsMoodTracker />
}
export default App

View File

@ -0,0 +1,495 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
// Year-in-Pixels Mood Tracker
// - 12 columns (months)
// - 31 rows (days)
// - Each cell = one calendar day for the selected year
// - 3 moods: Green (G), Amber (A), Red (R)
// - Data persists to localStorage per-year
// - Click to cycle Empty → G → A → R → Empty
// - Drag painting with a selected color (optional): pick a color, then drag
// - Export/Import JSON, Export CSV, Print
// - Highlights today's date when viewing the current year
// Tailwind color classes for moods
const MOOD_STYLES: Record<string, string> = {
"": "bg-zinc-200 dark:bg-zinc-700",
G: "bg-green-500",
A: "bg-amber-400",
R: "bg-red-500",
};
const MOOD_LABELS: Record<string, string> = {
"": "No entry",
G: "Green",
A: "Amber",
R: "Red",
};
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
function pad(n: number) {
return String(n).padStart(2, "0");
}
function daysInMonth(year: number, monthIndex0: number) {
// monthIndex0 = 0..11
return new Date(year, monthIndex0 + 1, 0).getDate();
}
function ymd(year: number, m0: number, d: number) {
return `${year}-${pad(m0 + 1)}-${pad(d)}`;
}
function download(filename: string, contents: string, type = "text/plain") {
const blob = new Blob([contents], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export default function YearInPixelsMoodTracker() {
const now = new Date();
const [year, setYear] = useState<number>(now.getFullYear());
const [moods, setMoods] = useState<Record<string, "" | "G" | "A" | "R">>({});
const [paintColor, setPaintColor] = useState<"" | "G" | "A" | "R">("");
const [showNumbers, setShowNumbers] = useState(true);
const [compact, setCompact] = useState(false);
const [showLegend, setShowLegend] = useState(true);
// drag-paint state
const isDraggingRef = useRef(false);
const paintColorRef = useRef<"" | "G" | "A" | "R">(paintColor);
useEffect(() => {
paintColorRef.current = paintColor;
}, [paintColor]);
// Load/save from localStorage
const storageKey = useMemo(() => `year-in-pixels-mood-${year}`, [year]);
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
if (raw.length > 100000) {
console.error("Stored data too large, clearing");
localStorage.removeItem(storageKey);
setMoods({});
return;
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
const validatedMoods: Record<string, "" | "G" | "A" | "R"> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof key === "string" && /^\d{4}-\d{2}-\d{2}$/.test(key)) {
if (value === "" || value === "G" || value === "A" || value === "R") {
validatedMoods[key] = value as "" | "G" | "A" | "R";
}
}
}
setMoods(validatedMoods);
} else {
setMoods({});
}
} else {
setMoods({});
}
} catch (e) {
console.error("Failed to load moods", e);
localStorage.removeItem(storageKey);
setMoods({});
}
}, [storageKey]);
useEffect(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(moods));
} catch (e) {
console.error("Failed to save moods", e);
}
}, [moods, storageKey]);
// Helpers
const todayKey = useMemo(() => {
const t = new Date();
return ymd(t.getFullYear(), t.getMonth(), t.getDate());
}, []);
function cycleMood(curr: "" | "G" | "A" | "R") {
if (curr === "") return "G";
if (curr === "G") return "A";
if (curr === "A") return "R";
return ""; // R → empty
}
function setMoodFor(key: string, value: "" | "G" | "A" | "R") {
setMoods((prev) => ({ ...prev, [key]: value }));
}
function handleCellClick(key: string) {
if (paintColor) {
setMoodFor(key, paintColor);
} else {
setMoods((prev) => ({ ...prev, [key]: cycleMood(prev[key] || "") }));
}
}
function handlePointerDown(e: React.PointerEvent, key: string) {
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
isDraggingRef.current = true;
if (paintColorRef.current) {
setMoodFor(key, paintColorRef.current);
} else {
setMoods((prev) => ({ ...prev, [key]: cycleMood(prev[key] || "") }));
}
}
function handlePointerEnter(_e: React.PointerEvent, key: string) {
if (!isDraggingRef.current) return;
if (paintColorRef.current) setMoodFor(key, paintColorRef.current);
}
function handlePointerUp() {
isDraggingRef.current = false;
}
// Stats
const stats = useMemo(() => {
const counts = { G: 0, A: 0, R: 0 } as Record<"G" | "A" | "R", number>;
for (const [k, v] of Object.entries(moods)) {
if (!k.startsWith(`${year}-`)) continue;
if (v === "G" || v === "A" || v === "R") counts[v]++;
}
return counts;
}, [moods, year]);
function resetYear() {
const next = { ...moods };
Object.keys(next)
.filter((k) => k.startsWith(`${year}-`))
.forEach((k) => delete next[k]);
setMoods(next);
}
function exportJSON() {
const data: Record<string, string> = {};
Object.entries(moods)
.filter(([k]) => k.startsWith(`${year}-`))
.forEach(([k, v]) => (data[k] = v));
download(`mood-${year}.json`, JSON.stringify({ year, data }, null, 2), "application/json");
}
function exportCSV() {
const rows: string[] = ["date,month,day,mood"];
for (let m = 0; m < 12; m++) {
const dim = daysInMonth(year, m);
for (let d = 1; d <= dim; d++) {
const key = ymd(year, m, d);
const mood = moods[key] || "";
rows.push(`${key},${MONTHS[m]},${d},${mood}`);
}
}
download(`mood-${year}.csv`, rows.join("\n"), "text/csv");
}
function importJSON(file: File) {
if (file.size > 1024 * 1024) {
alert("File too large. Maximum size is 1MB.");
return;
}
if (!file.type.includes("json") && !file.name.endsWith(".json")) {
alert("Please select a valid JSON file.");
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const result = String(reader.result);
if (result.length > 1024 * 1024) {
alert("File content too large.");
return;
}
const parsed = JSON.parse(result);
if (parsed && typeof parsed === "object" && parsed.data) {
const validatedData: Record<string, "" | "G" | "A" | "R"> = {};
for (const [key, value] of Object.entries(parsed.data)) {
if (typeof key === "string" && /^\d{4}-\d{2}-\d{2}$/.test(key)) {
if (value === "" || value === "G" || value === "A" || value === "R") {
validatedData[key] = value as "" | "G" | "A" | "R";
}
}
}
setMoods((prev) => ({ ...prev, ...validatedData }));
if (parsed.year && typeof parsed.year === "number" && parsed.year >= 1900 && parsed.year <= 3000) {
setYear(parsed.year);
}
} else {
alert("Invalid JSON format.");
}
} catch (e) {
alert("Failed to parse JSON. Please ensure the file is valid JSON.");
}
};
reader.readAsText(file);
}
// Rendering helpers
const Cell = ({ m0, d }: { m0: number; d: number }) => {
const dim = daysInMonth(year, m0);
const active = d <= dim;
const key = ymd(year, m0, d);
const mood = moods[key] || "";
if (!active) {
return (
<div
className="w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-md bg-zinc-100 dark:bg-zinc-800 opacity-50"
aria-hidden
/>
);
}
const isToday = key === todayKey;
return (
<button
aria-label={`${year}-${MONTHS[m0]} ${d}: ${MOOD_LABELS[mood]}`}
title={`${year}-${pad(m0 + 1)}-${pad(d)} (${MOOD_LABELS[mood]})`}
onClick={() => handleCellClick(key)}
onPointerDown={(e) => handlePointerDown(e, key)}
onPointerEnter={(e) => handlePointerEnter(e, key)}
onPointerUp={handlePointerUp}
className={[
"group w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-md border border-zinc-300/60 dark:border-zinc-600/60 flex items-center justify-center select-none",
MOOD_STYLES[mood],
isToday && year === new Date().getFullYear()
? "ring-2 ring-offset-1 ring-black/40 dark:ring-white/50"
: "",
].join(" ")}
>
{showNumbers && (
<span className="text-[10px] sm:text-[11px] md:text-xs font-medium text-black/80 dark:text-white/90">
{d}
</span>
)}
</button>
);
};
return (
<div className="min-h-screen w-full bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-50 p-4 md:p-6">
<div className="mx-auto max-w-6xl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Year in Pixels Mood (RAG)</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
12 columns (months) × 31 rows (days). Click to cycle or select a color to paint and drag across days.
</p>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
<button
className="px-2 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700"
onClick={() => setYear((y) => y - 1)}
aria-label="Previous year"
>
</button>
<input
type="number"
className="w-24 px-3 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-center"
value={year}
onChange={(e) => setYear(parseInt(e.target.value || `${now.getFullYear()}`, 10))}
aria-label="Year"
/>
<button
className="px-2 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700"
onClick={() => setYear((y) => y + 1)}
aria-label="Next year"
>
</button>
</div>
{/* Paint palette */}
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
<span className="text-xs mr-1">Paint:</span>
{(
[
{ k: "", name: "Off", cls: "bg-zinc-200 dark:bg-zinc-700" },
{ k: "G", name: "Green", cls: "bg-green-500" },
{ k: "A", name: "Amber", cls: "bg-amber-400" },
{ k: "R", name: "Red", cls: "bg-red-500" },
] as const
).map((opt) => (
<button
key={opt.k}
className={[
"w-7 h-7 rounded-lg border border-zinc-300/70 dark:border-zinc-600/70 flex items-center justify-center",
opt.cls,
paintColor === opt.k ? "ring-2 ring-black/40 dark:ring-white/70" : "",
].join(" ")}
onClick={() => setPaintColor(opt.k)}
title={`Paint: ${opt.name}`}
aria-label={`Paint color: ${opt.name}`}
>
{opt.k === "" ? (
<span className="text-[10px] font-medium text-zinc-800 dark:text-zinc-100">Off</span>
) : null}
</button>
))}
</div>
{/* Toggles */}
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
<label className="flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={showNumbers}
onChange={(e) => setShowNumbers(e.target.checked)}
/>
Day numbers
</label>
<label className="flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={compact}
onChange={(e) => setCompact(e.target.checked)}
/>
Compact
</label>
<label className="flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={showLegend}
onChange={(e) => setShowLegend(e.target.checked)}
/>
Legend
</label>
</div>
{/* Actions */}
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={exportJSON}>
Export JSON
</button>
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={exportCSV}>
Export CSV
</button>
<label className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700 cursor-pointer">
Import JSON
<input type="file" accept="application/json" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) importJSON(f);
e.currentTarget.value = "";
}} />
</label>
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={resetYear}>
Reset year
</button>
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={() => window.print()}>
Print
</button>
</div>
</div>
</div>
{/* Legend */}
{showLegend && (
<div className="mb-4 text-sm flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-green-500" /> Green</div>
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-amber-400" /> Amber</div>
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-red-500" /> Red</div>
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-zinc-200 dark:bg-zinc-700" /> No entry</div>
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm ring-2 ring-black/40 dark:ring-white/70" /> Today</div>
</div>
)}
{/* Summary */}
<div className="mb-6 text-sm text-zinc-700 dark:text-zinc-300">
<span className="mr-4">Green: <strong>{stats.G}</strong></span>
<span className="mr-4">Amber: <strong>{stats.A}</strong></span>
<span className="mr-4">Red: <strong>{stats.R}</strong></span>
</div>
{/* Month headers */}
<div className="grid grid-cols-12 gap-1 pl-8 sm:pl-10 md:pl-12 mb-2">
{MONTHS.map((m) => (
<div
key={m}
className="justify-self-start w-7 sm:w-8 md:w-9 text-center text-[11px] sm:text-sm font-medium text-zinc-600 dark:text-zinc-400"
>
{m}
</div>
))}
</div>
{/* Grid wrapper with day labels at left */}
<div className={[
"relative",
compact ? "-mt-1" : "",
].join(" ")}>
<div className="absolute -left-0 top-0 flex flex-col gap-1 pr-2">
{Array.from({ length: 31 }).map((_, i) => (
<div
key={i}
className="h-7 sm:h-8 md:h-9 w-8 sm:w-10 md:w-12 text-[10px] sm:text-xs md:text-sm text-right pr-1 text-zinc-500 dark:text-zinc-400 flex items-center justify-end"
>
{i + 1}
</div>
))}
</div>
{/* The 31 × 12 day grid */}
<div className="grid grid-cols-12 gap-1 pl-8 sm:pl-10 md:pl-12"
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerLeave={handlePointerUp}
>
{Array.from({ length: 31 }).map((_, dIndex) => (
<React.Fragment key={dIndex}>
{Array.from({ length: 12 }).map((_, mIndex) => (
<Cell key={`${mIndex}-${dIndex}`} m0={mIndex} d={dIndex + 1} />
))}
</React.Fragment>
))}
</div>
</div>
{/* Footer note */}
<div className="mt-8 text-xs text-zinc-500 dark:text-zinc-400">
Tip: Turn on a paint color to quickly fill multiple days by dragging across the grid. Toggle Day numbers for a cleaner look.
</div>
</div>
</div>
);
}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./*.{js,ts,jsx,tsx}"
],
theme: {
extend: {},
},
plugins: [],
darkMode: 'media'
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
server: {
host: '0.0.0.0',
port: 3000
}
})