added 1RM and macro calculators to fitness dashboard.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,64 @@
|
||||
import git from 'isomorphic-git';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = process.cwd();
|
||||
|
||||
async function run() {
|
||||
// 1. Initialize git repo if not present
|
||||
try {
|
||||
await git.init({ fs, dir, defaultBranch: 'main' });
|
||||
console.log('Git repository initialized.');
|
||||
} catch (err) {
|
||||
console.log('Git init note:', err.message);
|
||||
}
|
||||
|
||||
// 2. Scan and stage files ignoring node_modules, .next, .git
|
||||
console.log('Scanning repository files...');
|
||||
const matrix = await git.statusMatrix({
|
||||
fs,
|
||||
dir,
|
||||
filter: (filepath) => {
|
||||
if (filepath.startsWith('node_modules')) return false;
|
||||
if (filepath.startsWith('.next')) return false;
|
||||
if (filepath.startsWith('.git')) return false;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
let stagedCount = 0;
|
||||
for (const [filepath, headStatus, workdirStatus, stageStatus] of matrix) {
|
||||
if (workdirStatus === 0) {
|
||||
await git.remove({ fs, dir, filepath });
|
||||
} else {
|
||||
await git.add({ fs, dir, filepath });
|
||||
stagedCount++;
|
||||
}
|
||||
}
|
||||
console.log(`Staged ${stagedCount} files.`);
|
||||
|
||||
// 3. Create Commit
|
||||
const sha = await git.commit({
|
||||
fs,
|
||||
dir,
|
||||
author: {
|
||||
name: 'Parth',
|
||||
email: 'parth@example.com',
|
||||
},
|
||||
message: 'added 1RM and macro calculators to fitness dashboard.',
|
||||
});
|
||||
|
||||
console.log(`Commit created successfully: ${sha}`);
|
||||
|
||||
// 4. Verify Log
|
||||
const commits = await git.log({ fs, dir, depth: 3 });
|
||||
console.log('\n--- Git Commit Log ---');
|
||||
commits.forEach(c => {
|
||||
console.log(`Commit: ${c.oid.slice(0, 7)} | Author: ${c.commit.author.name} | Message: ${c.commit.message.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('Error committing:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7167
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "antigravity-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"isomorphic-git": "^1.42.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Bookmark } from "lucide-react";
|
||||
import { MOCK_POSTS } from "@/lib/mock-data";
|
||||
|
||||
// Reusing the same structure as the Profile tab for consistency
|
||||
const MOCK_BOARDS = [
|
||||
{ id: "b1", name: "Architecture Inspo", pins: 124, images: [MOCK_POSTS[2].mediaUrl, MOCK_POSTS[0].mediaUrl, MOCK_POSTS[4].mediaUrl] },
|
||||
{ id: "b2", name: "Streetwear 2024", pins: 89, images: [MOCK_POSTS[1].mediaUrl, MOCK_POSTS[5].mediaUrl, MOCK_POSTS[6].mediaUrl] },
|
||||
{ id: "b3", name: "Travel Bucketlist", pins: 342, images: [MOCK_POSTS[0].mediaUrl, MOCK_POSTS[7].mediaUrl, MOCK_POSTS[3].mediaUrl] },
|
||||
{ id: "b4", name: "Interior Minimal", pins: 56, images: [MOCK_POSTS[5].mediaUrl, MOCK_POSTS[2].mediaUrl, MOCK_POSTS[1].mediaUrl] },
|
||||
];
|
||||
|
||||
export default function BoardsPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto pt-8 pb-24 px-4 min-h-screen">
|
||||
<div className="flex items-center gap-3 mb-10">
|
||||
<Bookmark className="w-10 h-10 text-primary" />
|
||||
<h1 className="text-4xl font-extrabold text-foreground">Saved Boards</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{/* Create New Board Card */}
|
||||
<div className="aspect-[4/5] rounded-[2rem] border-2 border-dashed border-border flex flex-col items-center justify-center gap-4 hover:bg-muted/50 transition-colors cursor-pointer group glass">
|
||||
<div className="w-16 h-16 rounded-full bg-background flex items-center justify-center shadow-lg group-active:scale-95 transition-transform">
|
||||
<Plus className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<span className="font-bold text-muted-foreground">New Board</span>
|
||||
</div>
|
||||
|
||||
{MOCK_BOARDS.map((board) => (
|
||||
<div key={board.id} className="group cursor-pointer flex flex-col gap-4">
|
||||
{/* Stacked Polaroids */}
|
||||
<div className="relative aspect-[4/5] w-full">
|
||||
{board.images.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute inset-0 rounded-2xl glass p-2 pb-6 shadow-xl origin-bottom transition-all duration-300 pointer-events-none"
|
||||
style={{ zIndex: 3 - i }}
|
||||
whileHover={
|
||||
i === 0 ? { rotate: -6, x: -10, y: -5, scale: 1.05 } :
|
||||
i === 1 ? { rotate: 2, scale: 1.02, zIndex: 10 } :
|
||||
{ rotate: 8, x: 10, y: -5, scale: 1.05 }
|
||||
}
|
||||
initial={{ rotate: i === 0 ? 0 : i === 1 ? 4 : -4 }}
|
||||
>
|
||||
<img src={img} className="w-full h-full object-cover rounded-xl" alt="Pin" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold px-2 group-hover:text-primary transition-colors">{board.name}</h3>
|
||||
<p className="text-sm font-medium text-muted-foreground px-2">{board.pins} Pins</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { UploadCloud, Image as ImageIcon, Sparkles, Hash, X } from "lucide-react";
|
||||
import { MOCK_POSTS, MOCK_USERS } from "@/lib/mock-data";
|
||||
|
||||
const FILTERS = [
|
||||
{ name: "Normal", class: "" },
|
||||
{ name: "Vintage", class: "sepia contrast-125" },
|
||||
{ name: "Noir", class: "grayscale contrast-150" },
|
||||
{ name: "Vivid", class: "saturate-200 contrast-110" },
|
||||
{ name: "Fade", class: "opacity-80 saturate-50" },
|
||||
{ name: "Cool", class: "hue-rotate-15 saturate-150" }
|
||||
];
|
||||
|
||||
export default function CreatePostPage() {
|
||||
const router = useRouter();
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState("");
|
||||
const [caption, setCaption] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
};
|
||||
|
||||
const addTag = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
if (!tags.includes(tagInput.trim())) {
|
||||
setTags([...tags, tagInput.trim()]);
|
||||
}
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!preview) return;
|
||||
|
||||
const newPost = {
|
||||
id: `p${MOCK_POSTS.length + 1}-${Date.now()}`,
|
||||
author: MOCK_USERS[4], // The current mocked user (neo_tokyo)
|
||||
mediaUrl: preview,
|
||||
aspectRatio: "aspect-square", // Simplified for newly created posts
|
||||
isVideo: file?.type.startsWith("video/") || false,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
caption: caption,
|
||||
tags: tags,
|
||||
createdAt: "Just now",
|
||||
};
|
||||
|
||||
// Unshift to add to the beginning of the feed
|
||||
MOCK_POSTS.unshift(newPost);
|
||||
|
||||
// Navigate home
|
||||
router.push("/feed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto pt-8 pb-24 px-4 min-h-[80vh] flex flex-col items-center">
|
||||
<h1 className="text-4xl font-extrabold mb-8 flex items-center gap-3">
|
||||
<Sparkles className="w-8 h-8 text-primary" /> Create New Post
|
||||
</h1>
|
||||
|
||||
<div className="w-full glass-panel rounded-3xl p-6 shadow-2xl overflow-hidden relative border border-border">
|
||||
<AnimatePresence mode="wait">
|
||||
{!preview ? (
|
||||
<motion.div
|
||||
key="upload"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`w-full h-[500px] rounded-2xl border-4 border-dashed flex flex-col items-center justify-center transition-colors ${
|
||||
dragActive ? "border-primary bg-primary/10" : "border-border bg-background/50 hover:bg-muted/50"
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<UploadCloud className={`w-24 h-24 mb-6 transition-all duration-300 ${dragActive ? "text-primary scale-110" : "text-muted-foreground"}`} />
|
||||
<p className="text-2xl font-bold mb-2">Drag and drop your media here</p>
|
||||
<p className="text-muted-foreground mb-8">Photos or Videos (up to 50MB)</p>
|
||||
|
||||
<label className="px-8 py-4 bg-primary text-primary-foreground font-bold rounded-full cursor-pointer hover:bg-primary/90 active:scale-95 transition-all shadow-lg hover:shadow-primary/30">
|
||||
Browse Files
|
||||
<input type="file" className="hidden" accept="image/*,video/*" onChange={handleFileChange} />
|
||||
</label>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="editor"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row gap-8"
|
||||
>
|
||||
{/* Image Preview & Filters */}
|
||||
<div className="w-full md:w-1/2 space-y-6">
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-black shadow-inner">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className={`w-full h-full object-contain transition-all duration-300 ${activeFilter}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setPreview(null); setFile(null); }}
|
||||
className="absolute top-4 left-4 p-2 bg-black/50 hover:bg-black/70 text-white rounded-full backdrop-blur-md transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold px-2">Filters</h3>
|
||||
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-2 px-2">
|
||||
{FILTERS.map(filter => (
|
||||
<button
|
||||
key={filter.name}
|
||||
onClick={() => setActiveFilter(filter.class)}
|
||||
className={`flex flex-col items-center gap-2 flex-shrink-0 group`}
|
||||
>
|
||||
<div className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all ${activeFilter === filter.class ? "border-primary shadow-lg shadow-primary/30 ring-2 ring-primary ring-offset-2 ring-offset-background" : "border-transparent border-border"}`}>
|
||||
<img src={preview} className={`w-full h-full object-cover ${filter.class}`} alt={filter.name} />
|
||||
</div>
|
||||
<span className={`text-xs font-bold ${activeFilter === filter.class ? "text-primary" : "text-muted-foreground"}`}>
|
||||
{filter.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details & Tags */}
|
||||
<div className="w-full md:w-1/2 flex flex-col pt-2">
|
||||
<div className="space-y-6 flex-1">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Caption</label>
|
||||
<textarea
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="Write a captivating caption..."
|
||||
className="w-full h-32 bg-background/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Tags</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<AnimatePresence>
|
||||
{tags.map(tag => (
|
||||
<motion.span
|
||||
key={tag}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
className="bg-primary/10 text-primary border border-primary/20 px-3 py-1 rounded-full text-sm font-semibold flex items-center gap-1"
|
||||
>
|
||||
<Hash className="w-3 h-3" />
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="ml-1 hover:text-black dark:hover:text-white transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={addTag}
|
||||
placeholder="Type a tag and press Enter"
|
||||
className="w-full bg-background/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border flex gap-4">
|
||||
<button onClick={() => { setPreview(null); setFile(null); }} className="flex-1 py-4 font-bold rounded-full glass hover:bg-muted/50 transition-all">
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex-1 py-4 font-bold rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-lg hover:shadow-primary/30 active:scale-95"
|
||||
>
|
||||
Share Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Search, Compass, TrendingUp, Hash } from "lucide-react";
|
||||
import { MOCK_POSTS } from "@/lib/mock-data";
|
||||
import { PostCard } from "@/components/feed/PostCard";
|
||||
import { useState } from "react";
|
||||
|
||||
const TRENDING_TAGS = ["#streetwear", "#architecture", "#minimalism", "#neonLights", "#coffeeArt"];
|
||||
const CATEGORIES = ["For You", "Photography", "Fashion", "Tech", "Travel", "Art"];
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col pt-6 pb-24 px-4 min-h-screen" suppressHydrationWarning>
|
||||
|
||||
{/* Header & Search */}
|
||||
<div className="sticky top-0 z-40 bg-background/80 backdrop-blur-md pt-2 pb-6 flex flex-col gap-6">
|
||||
<h1 className="text-4xl font-extrabold flex items-center gap-3">
|
||||
<Compass className="w-10 h-10 text-primary" />
|
||||
Explore
|
||||
</h1>
|
||||
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<motion.div
|
||||
animate={{ scale: searchFocused ? 1.02 : 1 }}
|
||||
className={`flex items-center w-full rounded-2xl border transition-all glass ${
|
||||
searchFocused ? "border-primary ring-4 ring-primary/20 bg-background/80" : "border-border bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<Search className="w-6 h-6 ml-4 text-muted-foreground object-fill" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts, tags, or topics..."
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
className="w-full bg-transparent px-4 py-4 outline-none font-medium placeholder:text-muted-foreground"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{searchFocused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute top-full left-0 right-0 mt-4 p-4 glass-panel rounded-2xl shadow-2xl border border-border"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<span className="flex items-center gap-2 text-sm font-bold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
<TrendingUp className="w-4 h-4" /> Trending Searches
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TRENDING_TAGS.map(tag => (
|
||||
<span key={tag} className="px-3 py-1.5 rounded-full bg-muted text-sm font-medium hover:bg-primary hover:text-primary-foreground cursor-pointer transition-colors flex items-center gap-1">
|
||||
<Hash className="w-3 h-3" /> {tag.replace("#", "")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex overflow-x-auto no-scrollbar gap-3 pb-2">
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`px-6 py-2.5 rounded-full font-bold whitespace-nowrap transition-all active:scale-95 ${
|
||||
i === 0 ? "bg-primary text-primary-foreground shadow-md shadow-primary/30" : "glass hover:bg-white/40 dark:hover:bg-black/40"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry Grid (Reused Post Cards with mixed sizes for discovery layout) */}
|
||||
<motion.div
|
||||
layout
|
||||
className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6 mt-6"
|
||||
>
|
||||
{/* Shuffle the mock posts slightly to look different from Home Feed */}
|
||||
{[...MOCK_POSTS].reverse().map((post) => (
|
||||
<PostCard key={`explore-${post.id}`} post={post} />
|
||||
))}
|
||||
{MOCK_POSTS.map((post) => (
|
||||
<PostCard key={`explore-2-${post.id}`} post={post} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { StoriesBanner } from "@/components/feed/StoriesBanner";
|
||||
import { PostCard } from "@/components/feed/PostCard";
|
||||
import { MOCK_POSTS } from "@/lib/mock-data";
|
||||
|
||||
export default function FeedPage() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<StoriesBanner />
|
||||
|
||||
{/* Masonry Layout Container */}
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6 px-4 pb-24">
|
||||
{MOCK_POSTS.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Send, Phone, Video, Info, Check, CheckCheck, Smile, Image as ImageIcon, Paperclip } from "lucide-react";
|
||||
import { MOCK_USERS, MOCK_POSTS } from "@/lib/mock-data";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const INIT_MESSAGES = [
|
||||
{ id: 1, sender: "u1", text: "Hey! Did you see that new architecture post?", time: "10:24 AM", read: true },
|
||||
{ id: 2, sender: "me", text: "Yes! The lighting was incredible.", time: "10:25 AM", read: true },
|
||||
{ id: 3, sender: "u1", text: "I saved it to our shared board.", time: "10:26 AM", read: true },
|
||||
];
|
||||
|
||||
export default function MessagesPage() {
|
||||
const [activeChat, setActiveChat] = useState(MOCK_USERS[0]);
|
||||
const [messages, setMessages] = useState(INIT_MESSAGES);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputText.trim()) return;
|
||||
|
||||
const newMsg = { id: Date.now(), sender: "me", text: inputText, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), read: false };
|
||||
setMessages(prev => [...prev, newMsg]);
|
||||
setInputText("");
|
||||
|
||||
// Simulate partner typing and replying
|
||||
setIsTyping(true);
|
||||
setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now() + 1,
|
||||
sender: activeChat.id,
|
||||
text: "That sounds awesome! 😎",
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
read: true
|
||||
}]);
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full max-h-screen py-6 px-4 flex">
|
||||
<div className="w-full max-w-6xl mx-auto flex h-[85vh] glass-panel rounded-3xl overflow-hidden shadow-2xl border border-border">
|
||||
|
||||
{/* Sidebar / Chat List */}
|
||||
<div className="w-1/3 border-r border-border bg-background/50 flex flex-col">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h2 className="text-2xl font-extrabold">Messages</h2>
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search messages..."
|
||||
className="w-full bg-muted/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar p-3 space-y-1">
|
||||
{MOCK_USERS.slice(0, 5).map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => setActiveChat(user)}
|
||||
className={`w-full flex items-center gap-4 p-3 rounded-2xl transition-all ${activeChat.id === user.id ? "bg-primary text-primary-foreground shadow-lg" : "hover:bg-muted"}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img src={user.avatar} className="w-14 h-14 rounded-full object-cover shadow-sm bg-background" alt={user.username} />
|
||||
{user.hasStory && <span className="absolute bottom-0 right-0 w-3.5 h-3.5 bg-green-500 border-2 border-background rounded-full" />}
|
||||
</div>
|
||||
<div className="flex-1 text-left hidden sm:block">
|
||||
<h3 className="font-bold truncate text-sm">{user.username}</h3>
|
||||
<p className={`text-xs truncate ${activeChat.id === user.id ? "text-primary-foreground/80" : "text-muted-foreground"}`}>
|
||||
Active today
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col bg-background/30">
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-background/80 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={activeChat.avatar} className="w-12 h-12 rounded-full object-cover shadow-sm" alt={activeChat.username} />
|
||||
<div>
|
||||
<h3 className="font-bold">{activeChat.username}</h3>
|
||||
<p className="text-xs text-muted-foreground">Active now</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-primary">
|
||||
<button className="p-2 glass rounded-full hover:bg-muted/50 transition-colors"><Phone className="w-5 h-5" /></button>
|
||||
<button className="p-2 glass rounded-full hover:bg-muted/50 transition-colors"><Video className="w-5 h-5" /></button>
|
||||
<button className="p-2 glass rounded-full hover:bg-muted/50 transition-colors"><Info className="w-5 h-5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div className="text-center text-xs text-muted-foreground font-semibold my-4">Today</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{messages.map((msg, i) => {
|
||||
const isMe = msg.sender === "me";
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
key={msg.id}
|
||||
className={`flex flex-col ${isMe ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div className={`max-w-[70%] px-6 py-3 rounded-3xl ${isMe ? "bg-primary text-primary-foreground rounded-tr-sm shadow-lg shadow-primary/20" : "glass rounded-tl-sm shadow-sm"}`}>
|
||||
<p className="text-[15px] leading-relaxed">{msg.text}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1 px-2">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{msg.time}</span>
|
||||
{isMe && (
|
||||
msg.read ? <CheckCheck className="w-3 h-3 text-primary" /> : <Check className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Typing Indicator */}
|
||||
<AnimatePresence>
|
||||
{isTyping && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="flex items-start"
|
||||
>
|
||||
<div className="glass px-5 py-4 rounded-3xl rounded-tl-sm flex gap-1.5 items-center shadow-sm">
|
||||
<motion.div animate={{ y: [0, -5, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0 }} className="w-2 h-2 bg-muted-foreground rounded-full" />
|
||||
<motion.div animate={{ y: [0, -5, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.2 }} className="w-2 h-2 bg-muted-foreground rounded-full" />
|
||||
<motion.div animate={{ y: [0, -5, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.4 }} className="w-2 h-2 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Box */}
|
||||
<div className="p-4 bg-background/80 backdrop-blur-md border-t border-border">
|
||||
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||
<button type="button" className="p-3 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Paperclip className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex-1 relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Message..."
|
||||
className="w-full bg-muted/30 border border-border rounded-full py-3 pl-6 pr-12 outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
/>
|
||||
<button type="button" className="absolute right-3 p-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Smile className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputText.trim()}
|
||||
className={`p-3 rounded-full transition-all active:scale-90 ${inputText.trim() ? "bg-primary text-primary-foreground shadow-lg shadow-primary/30" : "bg-muted text-muted-foreground cursor-not-allowed"}`}
|
||||
>
|
||||
<Send className="w-5 h-5 ml-0.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Heart, MessageCircle, UserPlus, Bookmark, Bell } from "lucide-react";
|
||||
import { MOCK_USERS, MOCK_POSTS } from "@/lib/mock-data";
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
{ id: 1, type: "like", user: MOCK_USERS[1], post: MOCK_POSTS[0], time: "2m", text: "liked your post" },
|
||||
{ id: 2, type: "comment", user: MOCK_USERS[4], post: MOCK_POSTS[0], time: "15m", text: "commented: \"Amazing shot! 📸\"" },
|
||||
{ id: 3, type: "follow", user: MOCK_USERS[2], time: "1h", text: "started following you" },
|
||||
{ id: 4, type: "save", user: MOCK_USERS[3], post: MOCK_POSTS[2], time: "3h", text: "saved your post to their board 'Architecture Inspo'" },
|
||||
{ id: 5, type: "like", user: MOCK_USERS[5], post: MOCK_POSTS[1], time: "1d", text: "liked your post" },
|
||||
{ id: 6, type: "follow", user: MOCK_USERS[6], time: "1d", text: "started following you" },
|
||||
{ id: 7, type: "comment", user: MOCK_USERS[7], post: MOCK_POSTS[1], time: "2d", text: "commented: \"Where did you get that jacket? 🔥\"" },
|
||||
];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
show: { opacity: 1, x: 0, transition: { type: "spring" as const, stiffness: 300, damping: 24 } }
|
||||
};
|
||||
|
||||
const renderIcon = (type: string) => {
|
||||
switch(type) {
|
||||
case "like": return <Heart className="w-5 h-5 text-red-500 fill-red-500" />;
|
||||
case "comment": return <MessageCircle className="w-5 h-5 text-blue-500 fill-blue-500" />;
|
||||
case "follow": return <UserPlus className="w-5 h-5 text-primary" />;
|
||||
case "save": return <Bookmark className="w-5 h-5 text-yellow-500 fill-yellow-500" />;
|
||||
default: return <Bell className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto pt-8 pb-24 px-4 min-h-[80vh]">
|
||||
<div className="flex items-center gap-4 mb-8 sticky top-4 z-40 bg-background/80 backdrop-blur-md py-4 rounded-3xl px-6 border border-border shadow-sm">
|
||||
<div className="p-3 bg-primary/10 rounded-2xl">
|
||||
<Bell className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Activity</h1>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="font-bold text-muted-foreground ml-2 uppercase tracking-widest text-xs">This Week</h3>
|
||||
</div>
|
||||
|
||||
{NOTIFICATIONS.map((notif) => (
|
||||
<motion.div
|
||||
key={notif.id}
|
||||
variants={item}
|
||||
className="group relative flex items-center gap-4 p-4 rounded-[2rem] glass-card border border-border/50 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
>
|
||||
{/* User Avatar with Type Badge */}
|
||||
<div className="relative">
|
||||
<img src={notif.user.avatar} alt={notif.user.username} className="w-14 h-14 rounded-full object-cover shadow-md bg-background" />
|
||||
<div className="absolute -bottom-1 -right-1 p-1.5 bg-background rounded-full shadow-sm">
|
||||
{renderIcon(notif.type)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Text */}
|
||||
<div className="flex-1 pr-4">
|
||||
<p className="text-[15px] leading-snug">
|
||||
<span className="font-bold text-foreground mr-1 hover:underline">{notif.user.username}</span>
|
||||
<span className="text-muted-foreground">{notif.text}</span>
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground/60 uppercase tracking-widest mt-1">
|
||||
{notif.time}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trailing Image / Action */}
|
||||
{notif.post ? (
|
||||
<img src={notif.post.mediaUrl} className="w-14 h-14 rounded-xl object-cover shadow-sm mr-2" alt="Post thumbnail" />
|
||||
) : (
|
||||
notif.type === "follow" && (
|
||||
<button className="px-5 py-2 mr-2 rounded-full font-bold text-sm bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 transition-all shadow-md">
|
||||
Follow Back
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Settings, Plus, MapPin, Link as LinkIcon, Calendar, User } from "lucide-react";
|
||||
import { MOCK_POSTS, MOCK_USERS } from "@/lib/mock-data";
|
||||
import { PostCard } from "@/components/feed/PostCard";
|
||||
|
||||
const TABS = ["Created", "Saved Boards", "Tagged In"];
|
||||
|
||||
const MOCK_BOARDS = [
|
||||
{ id: "b1", name: "Architecture Inspo", pins: 124, images: [MOCK_POSTS[2].mediaUrl, MOCK_POSTS[0].mediaUrl, MOCK_POSTS[4].mediaUrl] },
|
||||
{ id: "b2", name: "Streetwear 2024", pins: 89, images: [MOCK_POSTS[1].mediaUrl, MOCK_POSTS[5].mediaUrl, MOCK_POSTS[6].mediaUrl] },
|
||||
{ id: "b3", name: "Travel Bucketlist", pins: 342, images: [MOCK_POSTS[0].mediaUrl, MOCK_POSTS[7].mediaUrl, MOCK_POSTS[3].mediaUrl] },
|
||||
{ id: "b4", name: "Interior Minimal", pins: 56, images: [MOCK_POSTS[5].mediaUrl, MOCK_POSTS[2].mediaUrl, MOCK_POSTS[1].mediaUrl] },
|
||||
];
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const currentUser = MOCK_USERS[4];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto pt-8 pb-24 px-4 min-h-screen">
|
||||
|
||||
{/* Profile Header Canvas */}
|
||||
<div className="relative w-full h-64 rounded-[2rem] overflow-hidden bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 shadow-xl border border-white/10">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<button className="absolute top-4 right-4 p-3 bg-black/20 hover:bg-black/40 text-white rounded-full backdrop-blur-md transition-colors">
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Profile Info */}
|
||||
<div className="relative px-8 flex flex-col sm:flex-row gap-8 items-start sm:items-end -mt-24 mb-12">
|
||||
<div className="w-40 h-40 rounded-full border-4 border-background p-1 bg-gradient-to-tr from-yellow-400 via-red-500 to-pink-500 shadow-2xl relative z-10 shrink-0 bg-background">
|
||||
<img src={currentUser.avatar} alt="Profile" className="w-full h-full rounded-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 pt-24 sm:pt-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold">{currentUser.username}</h1>
|
||||
<p className="text-muted-foreground font-medium">Digital Creator & Developer</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-8 py-2.5 rounded-full bg-primary text-primary-foreground font-bold hover:bg-primary/90 transition-all active:scale-95 shadow-lg shadow-primary/20">
|
||||
Share Profile
|
||||
</button>
|
||||
<button className="px-8 py-2.5 rounded-full glass font-bold hover:bg-muted transition-all active:scale-95">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground font-medium">
|
||||
<span className="flex items-center gap-1.5"><MapPin className="w-4 h-4" /> Tokyo, Japan</span>
|
||||
<span className="flex items-center gap-1.5 text-primary hover:underline cursor-pointer"><LinkIcon className="w-4 h-4" /> pinstagram.app/neo_tokyo</span>
|
||||
<span className="flex items-center gap-1.5"><Calendar className="w-4 h-4" /> Joined April 2024</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 pt-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-extrabold text-foreground">1,245</span>
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Followers</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-extrabold text-foreground">342</span>
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Following</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-extrabold text-foreground">89</span>
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Pins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated Tabs */}
|
||||
<div className="relative w-full flex justify-center border-b border-border/50 mb-8">
|
||||
{TABS.map((tab, idx) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(idx)}
|
||||
className={`relative px-8 py-4 text-sm font-bold uppercase tracking-widest transition-colors ${
|
||||
activeTab === idx ? "text-foreground" : "text-muted-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === idx && (
|
||||
<motion.div
|
||||
layoutId="profileTabIndicator"
|
||||
className="absolute bottom-0 left-0 w-full h-0.5 bg-primary"
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[50vh]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeTab === 0 && (
|
||||
<div className="columns-1 sm:columns-2 lg:columns-3 gap-6 space-y-6">
|
||||
{MOCK_POSTS.slice(0, 5).map((post) => (
|
||||
<PostCard key={`created-${post.id}`} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 pt-4">
|
||||
{/* Create New Board Card */}
|
||||
<div className="aspect-[4/5] rounded-[2rem] border-2 border-dashed border-border flex flex-col items-center justify-center gap-4 hover:bg-muted/50 transition-colors cursor-pointer group">
|
||||
<div className="w-16 h-16 rounded-full bg-background flex items-center justify-center shadow-lg group-active:scale-95 transition-transform">
|
||||
<Plus className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<span className="font-bold text-muted-foreground">New Board</span>
|
||||
</div>
|
||||
|
||||
{MOCK_BOARDS.map((board) => (
|
||||
<div key={board.id} className="group cursor-pointer flex flex-col gap-4">
|
||||
{/* Stacked Polaroids */}
|
||||
<div className="relative aspect-[4/5] w-full">
|
||||
{board.images.map((img, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute inset-0 rounded-2xl glass p-2 pb-6 shadow-xl origin-bottom transition-all duration-300 pointer-events-none"
|
||||
style={{ zIndex: 3 - i }}
|
||||
whileHover={
|
||||
i === 0 ? { rotate: -6, x: -10, y: -5, scale: 1.05 } :
|
||||
i === 1 ? { rotate: 2, scale: 1.02, zIndex: 10 } :
|
||||
{ rotate: 8, x: 10, y: -5, scale: 1.05 }
|
||||
}
|
||||
initial={{ rotate: i === 0 ? 0 : i === 1 ? 4 : -4 }}
|
||||
>
|
||||
<img src={img} className="w-full h-full object-cover rounded-xl" alt="Pin" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold px-2">{board.name}</h3>
|
||||
<p className="text-sm font-medium text-muted-foreground px-2">{board.pins} Pins</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center space-y-4">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-dashed border-muted-foreground/30 flex items-center justify-center mb-2">
|
||||
<User className="w-10 h-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-foreground">Photos of you</h3>
|
||||
<p className="text-muted-foreground">When people tag you in photos, they'll appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export default function AuthPage() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden bg-background text-foreground">
|
||||
<div className="absolute top-4 right-4 z-50">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Dynamic Backgrounds */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 90, 0]
|
||||
}}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-[-10%] left-[-10%] w-[600px] h-[600px] bg-primary/10 rounded-full blur-[120px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.5, 1],
|
||||
rotate: [0, -90, 0]
|
||||
}}
|
||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-pink-500/10 rounded-full blur-[120px]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-panel p-10 rounded-3xl w-full max-w-md z-10 flex flex-col gap-6"
|
||||
>
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight">Pinstagram</h1>
|
||||
<p className="text-muted-foreground">{isLogin ? "Welcome back to your curated world." : "Join the visual discovery."}</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-4 mt-4" onSubmit={(e) => { e.preventDefault(); window.location.href = "/onboarding"; }}>
|
||||
{!isLogin && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input type="text" className="w-full bg-background/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all" placeholder="creative_soul" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<input type="email" required className="w-full bg-background/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input type="password" required className="w-full bg-background/50 border border-border rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-primary/50 transition-all" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full bg-primary text-primary-foreground font-semibold py-3 rounded-xl mt-2 hover:bg-primary/90 transition-all active:scale-[0.98] shadow-lg">
|
||||
{isLogin ? "Sign In" : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative flex items-center py-2">
|
||||
<div className="flex-grow border-t border-border"></div>
|
||||
<span className="flex-shrink-0 mx-4 text-muted-foreground text-sm">or continue with</span>
|
||||
<div className="flex-grow border-t border-border"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button className="flex-1 glass py-3 rounded-xl font-medium hover:bg-white/40 dark:hover:bg-black/40 transition-all flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /><path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /><path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /><path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /></svg>
|
||||
Google
|
||||
</button>
|
||||
<button className="flex-1 glass py-3 rounded-xl font-medium hover:bg-white/40 dark:hover:bg-black/40 transition-all flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5 text-black dark:text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-4">
|
||||
{isLogin ? "Don't have an account?" : "Already have an account?"}{" "}
|
||||
<button onClick={() => setIsLogin(!isLogin)} className="text-foreground font-semibold hover:underline">
|
||||
{isLogin ? "Sign up" : "Sign in"}
|
||||
</button>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,154 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-shimmer: shimmer 2s linear infinite;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
backgroundPosition: 200% 0;
|
||||
}
|
||||
to {
|
||||
backgroundPosition: -200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #f8fafc;
|
||||
--foreground: #020817;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #020817;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #020817;
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--primary: #0f172a;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #0f172a;
|
||||
--accent: #f1f5f9;
|
||||
--accent-foreground: #0f172a;
|
||||
--ring: #94a3b8;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #020817;
|
||||
--foreground: #f8fafc;
|
||||
--muted: #0f172a;
|
||||
--muted-foreground: #94a3b8;
|
||||
--popover: #020817;
|
||||
--popover-foreground: #f8fafc;
|
||||
--card: #020817;
|
||||
--card-foreground: #f8fafc;
|
||||
--border: #1e293b;
|
||||
--input: #1e293b;
|
||||
--primary: #e2e8f0;
|
||||
--primary-foreground: #020817;
|
||||
--secondary: #0f172a;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--ring: #334155;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
@apply bg-white/5 backdrop-blur-xl border border-white/10 shadow-lg;
|
||||
}
|
||||
|
||||
@utility glass-card {
|
||||
@apply bg-[#111726]/80 backdrop-blur-md border border-slate-800/80 rounded-2xl shadow-xl hover:border-slate-700/80 transition-all;
|
||||
}
|
||||
|
||||
@utility glass-panel {
|
||||
@apply bg-[#0c1220]/90 backdrop-blur-2xl border border-slate-800 shadow-2xl;
|
||||
}
|
||||
|
||||
@utility text-gradient-cyan {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 via-teal-300 to-emerald-400;
|
||||
}
|
||||
|
||||
@utility text-gradient-purple {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400;
|
||||
}
|
||||
|
||||
@utility glow-cyan {
|
||||
box-shadow: 0 0 20px -3px rgba(6, 182, 212, 0.35);
|
||||
}
|
||||
|
||||
@utility glow-emerald {
|
||||
box-shadow: 0 0 20px -3px rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
@utility glow-purple {
|
||||
box-shadow: 0 0 20px -3px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
/* Custom Sleek Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #090d16;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1e293b;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #334155;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Parth's Life Manager | Personal Command Center",
|
||||
description: "An all-in-one personal dashboard for tasks, habits, calendar, expenses, notes, and focus.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[#090d16] text-[#e2e8f0] min-h-screen selection:bg-cyan-500/30 selection:text-cyan-200`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
const INTERESTS = [
|
||||
{ id: "fashion", label: "Streetwear", emoji: "👟" },
|
||||
{ id: "travel", label: "Travel & Places", emoji: "✈️" },
|
||||
{ id: "architecture", label: "Architecture", emoji: "🏛️" },
|
||||
{ id: "food", label: "Food & Drink", emoji: "🍔" },
|
||||
{ id: "tech", label: "Technology", emoji: "💻" },
|
||||
{ id: "art", label: "Digital Art", emoji: "🎨" },
|
||||
{ id: "music", label: "Music & Audio", emoji: "🎵" },
|
||||
{ id: "nature", label: "Nature", emoji: "🌲" },
|
||||
{ id: "fitness", label: "Health & Fitness", emoji: "🏋️" },
|
||||
{ id: "cars", label: "Automotive", emoji: "🚗" },
|
||||
];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const toggleInterest = (id: string) => {
|
||||
setSelected(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const item: import("framer-motion").Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8, y: 20 },
|
||||
show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 20 } }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col items-center justify-center p-8 relative">
|
||||
<div className="absolute top-4 right-4 z-50">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl w-full z-10 flex flex-col items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center space-y-4 mb-12"
|
||||
>
|
||||
<h1 className="text-5xl font-extrabold tracking-tight">What are you into?</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Pick 3 or more topics to personalize your feed. We'll curate the best content just for you.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex flex-wrap justify-center gap-4 max-w-3xl"
|
||||
>
|
||||
{INTERESTS.map((interest) => {
|
||||
const isSelected = selected.includes(interest.id);
|
||||
return (
|
||||
<motion.button
|
||||
key={interest.id}
|
||||
variants={item}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => toggleInterest(interest.id)}
|
||||
className={`flex items-center gap-3 px-6 py-4 rounded-full text-lg font-medium transition-all shadow-sm
|
||||
${isSelected
|
||||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/30 ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "glass hover:bg-white/50 dark:hover:bg-black/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-2xl">{interest.emoji}</span>
|
||||
{interest.label}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: selected.length > 0 ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mt-16"
|
||||
>
|
||||
<button
|
||||
disabled={selected.length < 3}
|
||||
onClick={() => window.location.href = "/feed"}
|
||||
className={`px-12 py-4 rounded-full font-bold text-lg shadow-xl transition-all ${
|
||||
selected.length >= 3
|
||||
? "bg-primary text-primary-foreground hover:scale-105 active:scale-95"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
{selected.length < 3 ? `Pick ${3 - selected.length} more` : "Continue to Feed →"}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { Header } from '@/components/Header';
|
||||
import { DashboardView } from '@/components/modules/DashboardView';
|
||||
import { PomodoroView } from '@/components/modules/PomodoroView';
|
||||
import { TasksView } from '@/components/modules/TasksView';
|
||||
import { HabitsView } from '@/components/modules/HabitsView';
|
||||
import { CalendarView } from '@/components/modules/CalendarView';
|
||||
import { FinanceView } from '@/components/modules/FinanceView';
|
||||
import { NotesView } from '@/components/modules/NotesView';
|
||||
import { FitnessView } from '@/components/modules/FitnessView';
|
||||
import { useLocalStorage } from '@/lib/useLocalStorage';
|
||||
import {
|
||||
INITIAL_TASKS,
|
||||
INITIAL_HABITS,
|
||||
INITIAL_EXPENSES,
|
||||
INITIAL_EVENTS,
|
||||
INITIAL_NOTES,
|
||||
INITIAL_WORKOUTS,
|
||||
INITIAL_NUTRITION,
|
||||
INITIAL_MUSCLE_RECOVERY,
|
||||
getFormattedDate
|
||||
} from '@/lib/initialData';
|
||||
import {
|
||||
Task,
|
||||
Habit,
|
||||
ExpenseItem,
|
||||
CalendarEvent,
|
||||
Note,
|
||||
ActiveModule,
|
||||
TaskStatus,
|
||||
WorkoutSession,
|
||||
DailyNutrition,
|
||||
MuscleRecovery
|
||||
} from '@/lib/types';
|
||||
|
||||
export default function Home() {
|
||||
const [activeModule, setActiveModule] = useState<ActiveModule>('dashboard');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(false);
|
||||
|
||||
// Quick action triggers
|
||||
const [showQuickAddTask, setShowQuickAddTask] = useState<boolean>(false);
|
||||
const [showQuickAddNote, setShowQuickAddNote] = useState<boolean>(false);
|
||||
|
||||
// Persistent States
|
||||
const [tasks, setTasks] = useLocalStorage<Task[]>('plm_tasks', INITIAL_TASKS);
|
||||
const [habits, setHabits] = useLocalStorage<Habit[]>('plm_habits', INITIAL_HABITS);
|
||||
const [expenses, setExpenses] = useLocalStorage<ExpenseItem[]>('plm_expenses', INITIAL_EXPENSES);
|
||||
const [events, setEvents] = useLocalStorage<CalendarEvent[]>('plm_events', INITIAL_EVENTS);
|
||||
const [notes, setNotes] = useLocalStorage<Note[]>('plm_notes', INITIAL_NOTES);
|
||||
const [workouts, setWorkouts] = useLocalStorage<WorkoutSession[]>('plm_workouts', INITIAL_WORKOUTS);
|
||||
const [nutrition, setNutrition] = useLocalStorage<DailyNutrition>('plm_nutrition', INITIAL_NUTRITION);
|
||||
const [muscleRecovery, setMuscleRecovery] = useLocalStorage<MuscleRecovery[]>('plm_recovery', INITIAL_MUSCLE_RECOVERY);
|
||||
|
||||
// Task Operations
|
||||
const handleAddTask = (newTask: Omit<Task, 'id' | 'createdAt'>) => {
|
||||
const task: Task = {
|
||||
...newTask,
|
||||
id: `t-${Date.now()}`,
|
||||
createdAt: getFormattedDate(0),
|
||||
};
|
||||
setTasks((prev) => [task, ...prev]);
|
||||
};
|
||||
|
||||
const handleUpdateTask = (id: string, updates: Partial<Task>) => {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, ...updates } : t))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteTask = (id: string) => {
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === taskId) {
|
||||
const newStatus: TaskStatus = t.status === 'completed' ? 'todo' : 'completed';
|
||||
return { ...t, status: newStatus };
|
||||
}
|
||||
return t;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Habit Operations
|
||||
const handleToggleHabit = (habitId: string, dateStr: string = getFormattedDate(0)) => {
|
||||
setHabits((prev) =>
|
||||
prev.map((h) => {
|
||||
if (h.id === habitId) {
|
||||
const isDone = h.completedDates.includes(dateStr);
|
||||
let updatedDates: string[];
|
||||
if (isDone) {
|
||||
updatedDates = h.completedDates.filter((d) => d !== dateStr);
|
||||
} else {
|
||||
updatedDates = [...h.completedDates, dateStr];
|
||||
}
|
||||
|
||||
// Compute new streak
|
||||
const newStreak = isDone ? Math.max(0, h.streak - 1) : h.streak + 1;
|
||||
const newBestStreak = Math.max(h.bestStreak, newStreak);
|
||||
|
||||
return {
|
||||
...h,
|
||||
completedDates: updatedDates,
|
||||
streak: newStreak,
|
||||
bestStreak: newBestStreak,
|
||||
};
|
||||
}
|
||||
return h;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddHabit = (newHabit: Omit<Habit, 'id' | 'streak' | 'bestStreak' | 'completedDates'>) => {
|
||||
const habit: Habit = {
|
||||
...newHabit,
|
||||
id: `h-${Date.now()}`,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
completedDates: [],
|
||||
};
|
||||
setHabits((prev) => [...prev, habit]);
|
||||
};
|
||||
|
||||
const handleDeleteHabit = (habitId: string) => {
|
||||
setHabits((prev) => prev.filter((h) => h.id !== habitId));
|
||||
};
|
||||
|
||||
// Event Operations
|
||||
const handleAddEvent = (newEvent: Omit<CalendarEvent, 'id'>) => {
|
||||
const event: CalendarEvent = {
|
||||
...newEvent,
|
||||
id: `ev-${Date.now()}`,
|
||||
};
|
||||
setEvents((prev) => [...prev, event]);
|
||||
};
|
||||
|
||||
const handleDeleteEvent = (id: string) => {
|
||||
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
// Finance Operations
|
||||
const handleAddTransaction = (newTx: Omit<ExpenseItem, 'id'>) => {
|
||||
const tx: ExpenseItem = {
|
||||
...newTx,
|
||||
id: `e-${Date.now()}`,
|
||||
};
|
||||
setExpenses((prev) => [tx, ...prev]);
|
||||
};
|
||||
|
||||
const handleDeleteTransaction = (id: string) => {
|
||||
setExpenses((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
// Note Operations
|
||||
const handleAddNote = (newNote: Omit<Note, 'id' | 'updatedAt'>) => {
|
||||
const note: Note = {
|
||||
...newNote,
|
||||
id: `n-${Date.now()}`,
|
||||
updatedAt: getFormattedDate(0),
|
||||
};
|
||||
setNotes((prev) => [note, ...prev]);
|
||||
};
|
||||
|
||||
const handleUpdateNote = (id: string, updates: Partial<Note>) => {
|
||||
setNotes((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, ...updates, updatedAt: getFormattedDate(0) } : n))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== id));
|
||||
};
|
||||
|
||||
// Fitness Operations
|
||||
const handleAddWorkout = (newWorkout: Omit<WorkoutSession, 'id'>) => {
|
||||
const session: WorkoutSession = {
|
||||
...newWorkout,
|
||||
id: `w-${Date.now()}`,
|
||||
};
|
||||
setWorkouts((prev) => [session, ...prev]);
|
||||
|
||||
// Automatically update muscle recovery states for trained muscle groups
|
||||
setMuscleRecovery((prev) =>
|
||||
prev.map((m) => {
|
||||
const trained = newWorkout.muscleGroups.some((mg) =>
|
||||
mg.toLowerCase().includes(m.muscle.toLowerCase()) || m.muscle.toLowerCase().includes(mg.toLowerCase())
|
||||
);
|
||||
if (trained) {
|
||||
return {
|
||||
...m,
|
||||
lastTrainedDaysAgo: 0,
|
||||
percentRecovered: 25,
|
||||
status: 'fatigued',
|
||||
};
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteWorkout = (id: string) => {
|
||||
setWorkouts((prev) => prev.filter((w) => w.id !== id));
|
||||
};
|
||||
|
||||
const handleUpdateNutrition = (updates: Partial<DailyNutrition>) => {
|
||||
setNutrition((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const pendingTasksCount = tasks.filter((t) => t.status !== 'completed').length;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#090d16] text-[#e2e8f0]">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar
|
||||
activeModule={activeModule}
|
||||
onSelectModule={(mod) => {
|
||||
setActiveModule(mod);
|
||||
setShowQuickAddTask(false);
|
||||
setShowQuickAddNote(false);
|
||||
}}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
pendingTasksCount={pendingTasksCount}
|
||||
habitsCount={habits.length}
|
||||
/>
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<Header
|
||||
activeModule={activeModule}
|
||||
onQuickAddTask={() => {
|
||||
setActiveModule('tasks');
|
||||
setShowQuickAddTask(true);
|
||||
}}
|
||||
onQuickAddNote={() => {
|
||||
setActiveModule('notes');
|
||||
setShowQuickAddNote(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable Module Canvas */}
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto animate-in fade-in duration-300">
|
||||
{activeModule === 'dashboard' && (
|
||||
<DashboardView
|
||||
tasks={tasks}
|
||||
habits={habits}
|
||||
expenses={expenses}
|
||||
events={events}
|
||||
notes={notes}
|
||||
onNavigate={(mod) => setActiveModule(mod)}
|
||||
onToggleTask={handleToggleTask}
|
||||
onToggleHabit={handleToggleHabit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'fitness' && (
|
||||
<FitnessView
|
||||
workouts={workouts}
|
||||
nutrition={nutrition}
|
||||
muscleRecovery={muscleRecovery}
|
||||
onAddWorkout={handleAddWorkout}
|
||||
onDeleteWorkout={handleDeleteWorkout}
|
||||
onUpdateNutrition={handleUpdateNutrition}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'pomodoro' && (
|
||||
<PomodoroView tasks={tasks} />
|
||||
)}
|
||||
|
||||
{activeModule === 'tasks' && (
|
||||
<TasksView
|
||||
tasks={tasks}
|
||||
onAddTask={handleAddTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
showAddModalDefault={showQuickAddTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'habits' && (
|
||||
<HabitsView
|
||||
habits={habits}
|
||||
onToggleHabit={handleToggleHabit}
|
||||
onAddHabit={handleAddHabit}
|
||||
onDeleteHabit={handleDeleteHabit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'calendar' && (
|
||||
<CalendarView
|
||||
events={events}
|
||||
onAddEvent={handleAddEvent}
|
||||
onDeleteEvent={handleDeleteEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'finance' && (
|
||||
<FinanceView
|
||||
expenses={expenses}
|
||||
onAddTransaction={handleAddTransaction}
|
||||
onDeleteTransaction={handleDeleteTransaction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModule === 'notes' && (
|
||||
<NotesView
|
||||
notes={notes}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateNote={handleUpdateNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
showAddModalDefault={showQuickAddNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Clock, Plus, Bell, Sparkles } from 'lucide-react';
|
||||
import { ActiveModule } from '@/lib/types';
|
||||
|
||||
interface HeaderProps {
|
||||
activeModule: ActiveModule;
|
||||
onQuickAddTask: () => void;
|
||||
onQuickAddNote: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
activeModule,
|
||||
onQuickAddTask,
|
||||
onQuickAddNote,
|
||||
}) => {
|
||||
const [timeString, setTimeString] = useState<string>('');
|
||||
const [dateString, setDateString] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setTimeString(
|
||||
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
);
|
||||
setDateString(
|
||||
now.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
|
||||
);
|
||||
};
|
||||
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const moduleTitles: Record<ActiveModule, { title: string; subtitle: string }> = {
|
||||
dashboard: {
|
||||
title: "Command Center",
|
||||
subtitle: "Welcome back, Parth. Here is your daily productivity pulse.",
|
||||
},
|
||||
fitness: {
|
||||
title: "Scientific Fitness & Nutrition Lab",
|
||||
subtitle: "Evidence-based hypertrophy, progressive overload tracking, and macronutrient fueling.",
|
||||
},
|
||||
pomodoro: {
|
||||
title: "Deep Work Focus Timer",
|
||||
subtitle: "Eliminate distractions and enter the flow state.",
|
||||
},
|
||||
tasks: {
|
||||
title: "Task & Project Board",
|
||||
subtitle: "Track, prioritize, and conquer your goals.",
|
||||
},
|
||||
habits: {
|
||||
title: "Habit Tracker & Streaks",
|
||||
subtitle: "Consistency breeds excellence. Don't break the chain!",
|
||||
},
|
||||
calendar: {
|
||||
title: "Calendar & Schedule",
|
||||
subtitle: "Plan your timeline and stay ahead of deadlines.",
|
||||
},
|
||||
finance: {
|
||||
title: "Personal Budget & Finance",
|
||||
subtitle: "Master your cash flow, investments, and expenses.",
|
||||
},
|
||||
notes: {
|
||||
title: "Notes & Reflection Journal",
|
||||
subtitle: "Capture ideas, mental models, and daily mood reflections.",
|
||||
},
|
||||
};
|
||||
|
||||
const current = moduleTitles[activeModule] || moduleTitles.dashboard;
|
||||
|
||||
return (
|
||||
<header className="h-20 border-b border-slate-800/80 bg-[#090d16]/80 backdrop-blur-xl px-6 flex items-center justify-between z-20 shrink-0">
|
||||
{/* Title & Subtitle */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-100 flex items-center gap-2">
|
||||
{current.title}
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/30">
|
||||
<Sparkles className="w-3 h-3" /> Active
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5 hidden sm:block">
|
||||
{current.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Clock & Quick Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Live Clock */}
|
||||
<div className="hidden md:flex items-center gap-2.5 px-3.5 py-1.5 rounded-xl bg-slate-900/80 border border-slate-800/80 text-xs">
|
||||
<Clock className="w-4 h-4 text-cyan-400 animate-pulse" />
|
||||
<div className="text-right">
|
||||
<div className="font-mono font-semibold text-slate-200">{timeString || '--:--:--'}</div>
|
||||
<div className="text-[10px] text-slate-400">{dateString || 'Loading...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onQuickAddTask}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-xs font-semibold transition-all hover:scale-105 active:scale-95 cursor-pointer shadow-sm"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">New Task</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onQuickAddNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-400 border border-purple-500/30 text-xs font-semibold transition-all hover:scale-105 active:scale-95 cursor-pointer shadow-sm"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">New Note</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CheckSquare,
|
||||
Flame,
|
||||
Calendar as CalendarIcon,
|
||||
Wallet,
|
||||
BookOpen,
|
||||
Timer,
|
||||
Sparkles,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
UserCheck,
|
||||
Dumbbell
|
||||
} from 'lucide-react';
|
||||
import { ActiveModule } from '@/lib/types';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ActiveModule;
|
||||
onSelectModule: (module: ActiveModule) => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
pendingTasksCount: number;
|
||||
habitsCount: number;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeModule,
|
||||
onSelectModule,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
pendingTasksCount,
|
||||
habitsCount,
|
||||
}) => {
|
||||
const navItems = [
|
||||
{
|
||||
id: 'dashboard' as ActiveModule,
|
||||
label: 'Command Center',
|
||||
icon: LayoutDashboard,
|
||||
badge: null,
|
||||
glowColor: 'group-hover:text-cyan-400',
|
||||
activeBg: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/40',
|
||||
},
|
||||
{
|
||||
id: 'fitness' as ActiveModule,
|
||||
label: 'Fitness Lab',
|
||||
icon: Dumbbell,
|
||||
badge: 'MPS',
|
||||
glowColor: 'group-hover:text-lime-400',
|
||||
activeBg: 'bg-lime-500/10 text-lime-400 border-lime-500/40',
|
||||
},
|
||||
{
|
||||
id: 'pomodoro' as ActiveModule,
|
||||
label: 'Focus Timer',
|
||||
icon: Timer,
|
||||
badge: '25m',
|
||||
glowColor: 'group-hover:text-amber-400',
|
||||
activeBg: 'bg-amber-500/10 text-amber-400 border-amber-500/40',
|
||||
},
|
||||
{
|
||||
id: 'tasks' as ActiveModule,
|
||||
label: 'Tasks & Kanban',
|
||||
icon: CheckSquare,
|
||||
badge: pendingTasksCount > 0 ? `${pendingTasksCount}` : null,
|
||||
glowColor: 'group-hover:text-emerald-400',
|
||||
activeBg: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/40',
|
||||
},
|
||||
{
|
||||
id: 'habits' as ActiveModule,
|
||||
label: 'Habits & Streaks',
|
||||
icon: Flame,
|
||||
badge: habitsCount > 0 ? `${habitsCount}` : null,
|
||||
glowColor: 'group-hover:text-orange-400',
|
||||
activeBg: 'bg-orange-500/10 text-orange-400 border-orange-500/40',
|
||||
},
|
||||
{
|
||||
id: 'calendar' as ActiveModule,
|
||||
label: 'Calendar Planner',
|
||||
icon: CalendarIcon,
|
||||
badge: null,
|
||||
glowColor: 'group-hover:text-blue-400',
|
||||
activeBg: 'bg-blue-500/10 text-blue-400 border-blue-500/40',
|
||||
},
|
||||
{
|
||||
id: 'finance' as ActiveModule,
|
||||
label: 'Budget & Finance',
|
||||
icon: Wallet,
|
||||
badge: null,
|
||||
glowColor: 'group-hover:text-teal-400',
|
||||
activeBg: 'bg-teal-500/10 text-teal-400 border-teal-500/40',
|
||||
},
|
||||
{
|
||||
id: 'notes' as ActiveModule,
|
||||
label: 'Notes & Journal',
|
||||
icon: BookOpen,
|
||||
badge: null,
|
||||
glowColor: 'group-hover:text-purple-400',
|
||||
activeBg: 'bg-purple-500/10 text-purple-400 border-purple-500/40',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`relative flex flex-col justify-between h-screen bg-[#0a0f1d] border-r border-slate-800/80 transition-all duration-300 z-30 select-none ${
|
||||
isCollapsed ? 'w-20' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Brand Header */}
|
||||
<div className="p-4 border-b border-slate-800/70">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-cyan-500 to-blue-600 flex items-center justify-center shadow-lg shadow-cyan-500/20 shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-base text-slate-100 tracking-tight whitespace-nowrap">
|
||||
Parth's Life
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-cyan-400 tracking-wider uppercase">
|
||||
Command Center
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/60 transition-colors"
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation List */}
|
||||
<div className="flex-1 py-4 px-3 space-y-1.5 overflow-y-auto">
|
||||
<div className="text-[11px] font-semibold text-slate-500 px-3 uppercase tracking-wider mb-2">
|
||||
{!isCollapsed ? 'Modules' : '•••'}
|
||||
</div>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeModule === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelectModule(item.id)}
|
||||
className={`w-full flex items-center gap-3.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all group relative border ${
|
||||
isActive
|
||||
? item.activeBg
|
||||
: 'text-slate-400 border-transparent hover:text-slate-200 hover:bg-slate-800/40 hover:border-slate-800/50'
|
||||
}`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 transition-transform group-hover:scale-110 ${isActive ? '' : item.glowColor}`} />
|
||||
|
||||
{!isCollapsed && (
|
||||
<span className="flex-1 text-left truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isCollapsed && item.badge && (
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-full ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'bg-slate-800 text-slate-400 border border-slate-700/50'
|
||||
}`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Collapsed Badge Dot */}
|
||||
{isCollapsed && item.badge && (
|
||||
<span className="absolute top-2 right-2 w-2 h-2 rounded-full bg-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* User / Status Footer */}
|
||||
<div className="p-3 border-t border-slate-800/70">
|
||||
<div className={`flex items-center gap-3 p-2 rounded-xl bg-slate-900/60 border border-slate-800/60 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500 via-indigo-500 to-cyan-400 flex items-center justify-center text-white font-bold text-sm shadow-md">
|
||||
P
|
||||
</div>
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-400 border-2 border-[#0a0f1d] rounded-full"></span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-slate-200 truncate">Parth</p>
|
||||
<div className="flex items-center gap-1 text-[10px] text-emerald-400 font-medium">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
<span>Focus Active</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Send, Heart } from "lucide-react";
|
||||
|
||||
interface CommentsSlideoutProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
post: any;
|
||||
}
|
||||
|
||||
const MOCK_COMMENTS = [
|
||||
{ id: "c1", user: "creative_soul", text: "This is absolutely stunning! 😍", likes: 12, time: "2h" },
|
||||
{ id: "c2", user: "design_guru", text: "Love the composition and lighting here. Inspiring work as always.", likes: 8, time: "5h" },
|
||||
{ id: "c3", user: "wanderlust.vibes", text: "Adding this to my bucket list rn 📝✨", likes: 45, time: "1d" },
|
||||
{ id: "c4", user: "minimal_ui", text: "Clean and perfect.", likes: 2, time: "1d" },
|
||||
];
|
||||
|
||||
export function CommentsSlideout({ isOpen, onClose, post }: CommentsSlideoutProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-background/50 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
|
||||
{/* Slideout Panel */}
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-y-0 right-0 w-full max-w-md glass-panel shadow-2xl z-[101] flex flex-col border-l border-border"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border bg-background/50">
|
||||
<h2 className="text-xl font-bold">Comments</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-muted flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Post Caption (First Comment conceptually) */}
|
||||
<div className="p-6 border-b border-border flex gap-4">
|
||||
<img src={post.author.avatar} alt="Author" className="w-10 h-10 rounded-full" />
|
||||
<div>
|
||||
<span className="font-bold mr-2">{post.author.username}</span>
|
||||
<span className="text-sm">{post.caption}</span>
|
||||
<p className="text-xs text-muted-foreground mt-2 uppercase tracking-widest">{post.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{MOCK_COMMENTS.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-4 group">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-pink-500 to-yellow-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="font-bold mr-2 text-sm">{comment.user}</span>
|
||||
<span className="text-sm">{comment.text}</span>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground font-semibold">
|
||||
<span>{comment.time}</span>
|
||||
<button className="hover:text-foreground transition-colors">{comment.likes} likes</button>
|
||||
<button className="hover:text-foreground transition-colors">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex-shrink-0 opacity-50 hover:opacity-100 group-hover:opacity-100 transition-opacity pt-1">
|
||||
<Heart className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border bg-background/50">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a comment..."
|
||||
className="w-full bg-transparent border border-border rounded-full py-3 px-6 pr-12 outline-none focus:ring-2 focus:ring-primary/50 transition-all placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button className="absolute right-2 p-2 rounded-full hover:bg-muted text-primary transition-colors">
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Heart, MessageCircle, Bookmark, Share2 } from "lucide-react";
|
||||
import { SaveBoardModal } from "@/components/feed/SaveBoardModal";
|
||||
import { CommentsSlideout } from "@/components/feed/CommentsSlideout";
|
||||
|
||||
interface PostCardProps {
|
||||
post: any;
|
||||
}
|
||||
|
||||
export function PostCard({ post }: PostCardProps) {
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [showHeart, setShowHeart] = useState(false);
|
||||
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
const [isCommentsOpen, setIsCommentsOpen] = useState(false);
|
||||
|
||||
const handleDoubleTap = () => {
|
||||
setIsLiked(true);
|
||||
setShowHeart(true);
|
||||
setTimeout(() => setShowHeart(false), 800);
|
||||
};
|
||||
|
||||
const handleSaveClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsSaveModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group relative break-inside-avoid mb-6 glass-card overflow-hidden w-full max-w-sm mx-auto shadow-sm hover:shadow-xl transition-all duration-500 object-cover">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<img src={post.author.avatar} alt={post.author.username} className="w-8 h-8 rounded-full object-cover shadow-sm" />
|
||||
<span className="font-semibold text-sm">{post.author.username}</span>
|
||||
</div>
|
||||
|
||||
{/* Media Content */}
|
||||
<div
|
||||
className={`relative w-full ${post.aspectRatio} overflow-hidden bg-muted cursor-pointer`}
|
||||
onDoubleClick={handleDoubleTap}
|
||||
>
|
||||
<img
|
||||
src={post.mediaUrl}
|
||||
alt={post.caption}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* Animated Double-Tap Heart */}
|
||||
<AnimatePresence>
|
||||
{showHeart && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 1 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.6, type: "spring" }}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none drop-shadow-2xl"
|
||||
>
|
||||
<Heart className="w-32 h-32 text-red-500 fill-red-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Hover Action Overlay */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => setIsLiked(!isLiked)} className="active:scale-90 transition-transform hover:scale-110">
|
||||
<Heart className={`w-7 h-7 drop-shadow-md ${isLiked ? "text-red-500 fill-red-500" : "text-white"}`} />
|
||||
</button>
|
||||
<button onClick={() => setIsCommentsOpen(true)} className="active:scale-90 transition-transform hover:scale-110">
|
||||
<MessageCircle className="w-7 h-7 text-white drop-shadow-md" />
|
||||
</button>
|
||||
<button className="active:scale-90 transition-transform hover:scale-110">
|
||||
<Share2 className="w-7 h-7 text-white drop-shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
className={`px-5 py-2 rounded-full font-bold text-sm transition-all active:scale-95 shadow-lg ${
|
||||
isSaved ? "bg-black text-white border-none" : "bg-red-600 hover:bg-red-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{isLiked ? post.likes + 1 : post.likes} likes
|
||||
</p>
|
||||
<p className="text-sm line-clamp-2">
|
||||
<span className="font-semibold mr-2">{post.author.username}</span>
|
||||
{post.caption}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-widest mt-1">
|
||||
{post.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SaveBoardModal
|
||||
isOpen={isSaveModalOpen}
|
||||
onClose={() => { setIsSaveModalOpen(false); setIsSaved(true); }}
|
||||
postMediaUrl={post.mediaUrl}
|
||||
/>
|
||||
<CommentsSlideout
|
||||
isOpen={isCommentsOpen}
|
||||
onClose={() => setIsCommentsOpen(false)}
|
||||
post={post}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Plus, Image as ImageIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface SaveModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
postMediaUrl: string;
|
||||
}
|
||||
|
||||
const MOCK_BOARDS = [
|
||||
{ id: "b1", name: "Architecture Inspo", cover: "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=100&q=80" },
|
||||
{ id: "b2", name: "Streetwear 2024", cover: "https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=100&q=80" },
|
||||
{ id: "b3", name: "Travel Bucketlist", cover: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&q=80" },
|
||||
];
|
||||
|
||||
export function SaveBoardModal({ isOpen, onClose, postMediaUrl }: SaveModalProps) {
|
||||
const [savedBoard, setSavedBoard] = useState<string | null>(null);
|
||||
|
||||
const handleSave = (boardId: string) => {
|
||||
setSavedBoard(boardId);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setSavedBoard(null);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.9, y: 20, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="w-full max-w-lg glass-panel rounded-3xl overflow-hidden shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold">Save to board</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-muted flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex p-6 gap-6">
|
||||
<div className="w-1/3 aspect-[3/4] rounded-xl overflow-hidden shadow-md">
|
||||
<img src={postMediaUrl} className="w-full h-full object-cover" alt="Post preview" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl border border-dashed border-border hover:bg-muted/50 transition-colors cursor-pointer text-muted-foreground group">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center group-hover:bg-background transition-colors">
|
||||
<Plus className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-medium group-hover:text-foreground transition-colors">Create new board</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-muted-foreground px-1">All boards</p>
|
||||
<div className="space-y-1 max-h-[40vh] overflow-y-auto no-scrollbar pr-2">
|
||||
{MOCK_BOARDS.map(board => (
|
||||
<button
|
||||
key={board.id}
|
||||
onClick={() => handleSave(board.id)}
|
||||
className="w-full flex items-center justify-between p-2 rounded-xl hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={board.cover} className="w-12 h-12 rounded-lg object-cover shadow-sm" alt={board.name} />
|
||||
<span className="font-semibold">{board.name}</span>
|
||||
</div>
|
||||
{savedBoard === board.id ? (
|
||||
<span className="px-3 py-1 bg-black text-white dark:bg-white dark:text-black rounded-full text-xs font-bold">
|
||||
Saved!
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-4 py-1.5 bg-primary text-primary-foreground rounded-full text-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Save
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { MOCK_USERS } from "@/lib/mock-data";
|
||||
|
||||
export function StoriesBanner() {
|
||||
return (
|
||||
<div className="w-full mb-8 pt-6 pb-2 overflow-x-auto no-scrollbar">
|
||||
<div className="flex gap-4 px-4 min-w-max">
|
||||
{/* Current User Add Story */}
|
||||
<div className="flex flex-col items-center gap-2 cursor-pointer relative group">
|
||||
<div className="w-16 h-16 rounded-full glass border-2 border-dashed border-muted-foreground flex items-center justify-center overflow-hidden transition-transform group-active:scale-95">
|
||||
<span className="text-2xl text-muted-foreground">+</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">Your Story</span>
|
||||
</div>
|
||||
|
||||
{MOCK_USERS.filter(u => u.hasStory).map((user, i) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="flex flex-col items-center gap-2 cursor-pointer group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full p-[2px] bg-gradient-to-tr from-yellow-400 via-red-500 to-pink-500 transition-transform group-hover:scale-105 group-active:scale-95">
|
||||
<div className="w-full h-full rounded-full border-2 border-background overflow-hidden relative">
|
||||
<img src={user.avatar} alt={user.username} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground truncate w-16 text-center">
|
||||
{user.username}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Home, Compass, PlusSquare, Heart, User, Send, Settings, Bookmark } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ icon: Home, label: "Home", href: "/feed" },
|
||||
{ icon: Compass, label: "Explore", href: "/explore" },
|
||||
{ icon: PlusSquare, label: "Create", href: "/create" },
|
||||
{ icon: Heart, label: "Notifications", href: "/notifications" },
|
||||
{ icon: Send, label: "Messages", href: "/messages" },
|
||||
{ icon: Bookmark, label: "Saved Boards", href: "/boards" },
|
||||
{ icon: User, label: "Profile", href: "/profile" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex" suppressHydrationWarning>
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex flex-col w-64 border-r border-border p-6 sticky top-0 h-screen overflow-y-auto glass-panel z-50">
|
||||
<div className="flex items-center justify-between mb-10" suppressHydrationWarning>
|
||||
<h1 className="text-2xl font-extrabold text-gradient">Pinstagram</h1>
|
||||
<ThemeToggle className="w-8 h-8 scale-75" />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-4 px-4 py-3 rounded-2xl transition-all font-medium ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
: "hover:bg-muted dark:hover:bg-white/5 active:scale-95"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`w-6 h-6 ${isActive ? "fill-primary-foreground/20" : ""}`} />
|
||||
<span className="text-lg">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-border" suppressHydrationWarning>
|
||||
<button className="flex items-center gap-4 px-4 py-3 rounded-2xl w-full hover:bg-muted dark:hover:bg-white/5 transition-all active:scale-95 font-medium">
|
||||
<Settings className="w-6 h-6" />
|
||||
<span className="text-lg">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 min-w-0 pb-20 md:pb-0 overflow-y-auto no-scrollbar">
|
||||
<div className="max-w-7xl mx-auto w-full" suppressHydrationWarning>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<nav className="md:hidden fixed bottom-0 w-full glass-panel border-t border-border flex justify-around items-center p-3 pb-safe z-50 text-foreground">
|
||||
{[
|
||||
{ icon: Home, href: "/feed" },
|
||||
{ icon: Compass, href: "/explore" },
|
||||
{ icon: PlusSquare, href: "/create" },
|
||||
{ icon: Heart, href: "/notifications" },
|
||||
{ icon: User, href: "/profile" },
|
||||
].map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className="p-3 relative">
|
||||
<item.icon className={`w-7 h-7 transition-all ${isActive ? "text-primary fill-primary/20 scale-110" : "text-muted-foreground"}`} />
|
||||
{isActive && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Clock,
|
||||
Trash2,
|
||||
Calendar as CalendarIcon,
|
||||
X,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { CalendarEvent } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface CalendarViewProps {
|
||||
events: CalendarEvent[];
|
||||
onAddEvent: (event: Omit<CalendarEvent, 'id'>) => void;
|
||||
onDeleteEvent: (id: string) => void;
|
||||
}
|
||||
|
||||
export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
events,
|
||||
onAddEvent,
|
||||
onDeleteEvent,
|
||||
}) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDateStr, setSelectedDateStr] = useState<string>(getFormattedDate(0));
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// New Event Form
|
||||
const [title, setTitle] = useState('');
|
||||
const [startTime, setStartTime] = useState('10:00');
|
||||
const [endTime, setEndTime] = useState('11:00');
|
||||
const [category, setCategory] = useState<'work' | 'personal' | 'study' | 'health'>('work');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Month navigation
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||
};
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const monthName = currentDate.toLocaleDateString([], { month: 'long', year: 'numeric' });
|
||||
|
||||
// Compute days in current month
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0 = Sunday
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
// Calendar cells: empty padding days + actual days
|
||||
const paddingDays = Array.from({ length: firstDayOfMonth }, (_, i) => i);
|
||||
const monthDays = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
onAddEvent({
|
||||
title: title.trim(),
|
||||
date: selectedDateStr,
|
||||
startTime,
|
||||
endTime,
|
||||
category,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setIsModalOpen(false);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const selectedDayEvents = events.filter((e) => e.date === selectedDateStr);
|
||||
|
||||
const getCategoryColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
case 'work':
|
||||
return 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30';
|
||||
case 'study':
|
||||
return 'bg-purple-500/20 text-purple-300 border-purple-500/30';
|
||||
case 'health':
|
||||
return 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30';
|
||||
default:
|
||||
return 'bg-amber-500/20 text-amber-300 border-amber-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left 2 Cols: Monthly Calendar Grid */}
|
||||
<div className="lg:col-span-2 glass-card p-6 space-y-6">
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-400">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-100">{monthName}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentDate(new Date());
|
||||
setSelectedDateStr(getFormattedDate(0));
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days of Week Header */}
|
||||
<div className="grid grid-cols-7 gap-2 text-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="py-1">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid Cells */}
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{paddingDays.map((p) => (
|
||||
<div key={`pad-${p}`} className="h-20 sm:h-24 rounded-xl bg-slate-950/20 opacity-30" />
|
||||
))}
|
||||
|
||||
{monthDays.map((day) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = dateStr === getFormattedDate(0);
|
||||
const isSelected = dateStr === selectedDateStr;
|
||||
const dayEvents = events.filter((e) => e.date === dateStr);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dateStr}
|
||||
onClick={() => setSelectedDateStr(dateStr)}
|
||||
className={`h-20 sm:h-24 p-2 rounded-xl border flex flex-col justify-between transition-all cursor-pointer select-none relative ${
|
||||
isSelected
|
||||
? 'bg-blue-950/40 border-blue-500 shadow-md shadow-blue-500/20'
|
||||
: isToday
|
||||
? 'bg-slate-900/90 border-cyan-500/50'
|
||||
: 'bg-slate-900/50 border-slate-800/80 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
isToday
|
||||
? 'bg-cyan-500 text-slate-950 shadow-sm shadow-cyan-500/50'
|
||||
: isSelected
|
||||
? 'text-blue-400 font-extrabold'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{dayEvents.length > 0 && (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.2 rounded-full bg-slate-800 text-slate-300">
|
||||
{dayEvents.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mini Dots / Previews */}
|
||||
<div className="space-y-1 overflow-hidden">
|
||||
{dayEvents.slice(0, 2).map((evt) => (
|
||||
<div
|
||||
key={evt.id}
|
||||
className={`text-[10px] truncate px-1 py-0.5 rounded border ${getCategoryColor(
|
||||
evt.category
|
||||
)}`}
|
||||
>
|
||||
{evt.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 2 && (
|
||||
<div className="text-[9px] text-slate-500 font-semibold pl-1">
|
||||
+{dayEvents.length - 2} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Col: Selected Day Agenda & Quick Add */}
|
||||
<div className="glass-card p-6 flex flex-col justify-between space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Daily Agenda
|
||||
</span>
|
||||
<h3 className="text-lg font-extrabold text-white mt-0.5">
|
||||
{new Date(selectedDateStr + 'T00:00:00').toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/40 text-xs font-bold transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Event</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agenda Event Cards */}
|
||||
<div className="mt-4 space-y-3 overflow-y-auto max-h-[420px]">
|
||||
{selectedDayEvents.length > 0 ? (
|
||||
selectedDayEvents.map((evt) => (
|
||||
<div
|
||||
key={evt.id}
|
||||
className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/90 hover:border-slate-700 transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-md border ${getCategoryColor(
|
||||
evt.category
|
||||
)}`}
|
||||
>
|
||||
{evt.category}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onDeleteEvent(evt.id)}
|
||||
className="text-slate-400 hover:text-red-400 p-1 rounded transition-colors"
|
||||
title="Delete Event"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4 className="mt-2 text-sm font-semibold text-slate-100">{evt.title}</h4>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Clock className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>
|
||||
{evt.startTime} - {evt.endTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evt.description && (
|
||||
<p className="mt-2 text-xs text-slate-400 leading-relaxed bg-slate-950/40 p-2 rounded-lg">
|
||||
{evt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-center text-slate-500 text-xs space-y-2">
|
||||
<Sparkles className="w-6 h-6 text-slate-600" />
|
||||
<p>No events scheduled for this day.</p>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-blue-400 hover:text-blue-300 font-semibold"
|
||||
>
|
||||
Schedule something now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Event Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-md rounded-2xl bg-[#0e1424] border border-blue-500/30 p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-blue-400" />
|
||||
Schedule Event
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Event Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Deep Work Session, Team Sync, Gym"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDateStr}
|
||||
onChange={(e) => setSelectedDateStr(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) =>
|
||||
setCategory(e.target.value as 'work' | 'personal' | 'study' | 'health')
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="work">Work</option>
|
||||
<option value="study">Study</option>
|
||||
<option value="health">Health</option>
|
||||
<option value="personal">Personal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Notes, agenda, or link..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-bold shadow-md shadow-blue-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
Save to Calendar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckSquare,
|
||||
Flame,
|
||||
Wallet,
|
||||
Timer,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Task, Habit, ExpenseItem, CalendarEvent, Note, ActiveModule } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface DashboardViewProps {
|
||||
tasks: Task[];
|
||||
habits: Habit[];
|
||||
expenses: ExpenseItem[];
|
||||
events: CalendarEvent[];
|
||||
notes: Note[];
|
||||
onNavigate: (module: ActiveModule) => void;
|
||||
onToggleTask: (taskId: string) => void;
|
||||
onToggleHabit: (habitId: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardView: React.FC<DashboardViewProps> = ({
|
||||
tasks,
|
||||
habits,
|
||||
expenses,
|
||||
events,
|
||||
notes,
|
||||
onNavigate,
|
||||
onToggleTask,
|
||||
onToggleHabit,
|
||||
}) => {
|
||||
const todayStr = getFormattedDate(0);
|
||||
|
||||
// Calculations
|
||||
const pendingTasks = tasks.filter(t => t.status !== 'completed');
|
||||
const urgentTasksCount = pendingTasks.filter(t => t.priority === 'urgent' || t.priority === 'high').length;
|
||||
|
||||
const habitsDoneToday = habits.filter(h => h.completedDates.includes(todayStr)).length;
|
||||
const habitCompletionRate = habits.length > 0 ? Math.round((habitsDoneToday / habits.length) * 100) : 0;
|
||||
const highestStreak = Math.max(...habits.map(h => h.streak), 0);
|
||||
|
||||
const totalIncome = expenses.filter(e => e.type === 'income').reduce((acc, curr) => acc + curr.amount, 0);
|
||||
const totalExpense = expenses.filter(e => e.type === 'expense').reduce((acc, curr) => acc + curr.amount, 0);
|
||||
const netSavings = totalIncome - totalExpense;
|
||||
|
||||
const todayEvents = events.filter(e => e.date === todayStr);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
{/* Welcome Hero Banner */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#11192e] via-[#0d1527] to-[#121c33] border border-cyan-500/20 p-6 md:p-8 shadow-2xl">
|
||||
<div className="absolute -right-12 -top-12 w-64 h-64 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -left-12 -bottom-12 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-xs font-semibold tracking-wide">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Personal Command System Active</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
Ready to crush your goals, <span className="text-gradient-cyan">Parth?</span>
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 max-w-xl">
|
||||
“Small daily disciplines repeated consistently lead to monumental success.” You have <strong className="text-slate-200">{pendingTasks.length} tasks</strong> remaining today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => onNavigate('pomodoro')}
|
||||
className="flex items-center gap-2 px-5 py-3 rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-slate-950 font-bold text-sm shadow-lg shadow-amber-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<Timer className="w-4 h-4" />
|
||||
<span>Start Focus Session</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('tasks')}
|
||||
className="flex items-center gap-2 px-5 py-3 rounded-2xl bg-slate-800/80 hover:bg-slate-800 text-slate-200 border border-slate-700/70 font-semibold text-sm transition-all hover:scale-105 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>View Kanban</span>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Metric 1: Pending Tasks */}
|
||||
<div
|
||||
onClick={() => onNavigate('tasks')}
|
||||
className="glass-card p-5 cursor-pointer hover:border-cyan-500/50 hover:shadow-cyan-500/10 group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Tasks Pending</span>
|
||||
<div className="p-2.5 rounded-xl bg-cyan-500/10 text-cyan-400 group-hover:scale-110 transition-transform">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{pendingTasks.length}</span>
|
||||
<span className="text-xs text-slate-500">active tasks</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-amber-400">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
<span>{urgentTasksCount} urgent / high priority</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 2: Habits Completion */}
|
||||
<div
|
||||
onClick={() => onNavigate('habits')}
|
||||
className="glass-card p-5 cursor-pointer hover:border-orange-500/50 hover:shadow-orange-500/10 group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Today's Habits</span>
|
||||
<div className="p-2.5 rounded-xl bg-orange-500/10 text-orange-400 group-hover:scale-110 transition-transform">
|
||||
<Flame className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{habitCompletionRate}%</span>
|
||||
<span className="text-xs text-slate-500">({habitsDoneToday}/{habits.length} done)</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-orange-400">
|
||||
<Flame className="w-3.5 h-3.5 fill-orange-400" />
|
||||
<span>Top Streak: {highestStreak} days strong</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 3: Net Cash Balance */}
|
||||
<div
|
||||
onClick={() => onNavigate('finance')}
|
||||
className="glass-card p-5 cursor-pointer hover:border-emerald-500/50 hover:shadow-emerald-500/10 group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Net Balance</span>
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400 group-hover:scale-110 transition-transform">
|
||||
<Wallet className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">
|
||||
${netSavings.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>${totalIncome} In / ${totalExpense} Out</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 4: Focus & Schedule */}
|
||||
<div
|
||||
onClick={() => onNavigate('calendar')}
|
||||
className="glass-card p-5 cursor-pointer hover:border-blue-500/50 hover:shadow-blue-500/10 group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Today's Agenda</span>
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-400 group-hover:scale-110 transition-transform">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{todayEvents.length}</span>
|
||||
<span className="text-xs text-slate-500">events scheduled</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-blue-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{todayEvents[0]?.title || "Clear schedule ahead"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Grid: Top Tasks & Habit Quick-Check */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Column 1: Priority Tasks */}
|
||||
<div className="glass-card p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="w-5 h-5 text-cyan-400" />
|
||||
<h3 className="font-bold text-slate-100 text-base">Top Priority Tasks</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('tasks')}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 font-semibold flex items-center gap-1"
|
||||
>
|
||||
Kanban View <ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tasks.slice(0, 4).map((task) => {
|
||||
const isDone = task.status === 'completed';
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-start gap-3 p-3.5 rounded-xl border transition-all ${
|
||||
isDone
|
||||
? 'bg-slate-900/40 border-slate-800/40 opacity-60'
|
||||
: 'bg-slate-900/70 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleTask(task.id);
|
||||
playNotificationChime(isDone ? 'click' : 'complete');
|
||||
}}
|
||||
className="mt-0.5 text-slate-400 hover:text-cyan-400 transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 fill-emerald-400/20" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={`text-sm font-semibold truncate ${isDone ? 'line-through text-slate-500' : 'text-slate-200'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border shrink-0 ${
|
||||
task.priority === 'urgent'
|
||||
? 'bg-red-500/10 text-red-400 border-red-500/30'
|
||||
: task.priority === 'high'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
: 'bg-blue-500/10 text-blue-400 border-blue-500/30'
|
||||
}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-slate-400 line-clamp-1 mt-0.5">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('tasks')}
|
||||
className="mt-4 w-full py-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 text-xs font-semibold text-slate-300 transition-colors border border-slate-700/60"
|
||||
>
|
||||
Manage All Tasks ({tasks.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Habits Quick Check-In */}
|
||||
<div className="glass-card p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="w-5 h-5 text-orange-400" />
|
||||
<h3 className="font-bold text-slate-100 text-base">Daily Habit Check-in</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('habits')}
|
||||
className="text-xs text-orange-400 hover:text-orange-300 font-semibold flex items-center gap-1"
|
||||
>
|
||||
Streak Matrix <ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{habits.map((habit) => {
|
||||
const isCompleted = habit.completedDates.includes(todayStr);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={habit.id}
|
||||
className={`flex items-center justify-between p-3.5 rounded-xl border transition-all ${
|
||||
isCompleted
|
||||
? 'bg-emerald-950/20 border-emerald-500/30'
|
||||
: 'bg-slate-900/70 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleHabit(habit.id);
|
||||
playNotificationChime(isCompleted ? 'click' : 'streak');
|
||||
}}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center border transition-all cursor-pointer ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500 border-emerald-400 text-slate-950 shadow-md shadow-emerald-500/30'
|
||||
: 'bg-slate-800/80 border-slate-700 text-slate-400 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-5 h-5 ${isCompleted ? 'stroke-[2.5]' : 'opacity-40'}`} />
|
||||
</button>
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${isCompleted ? 'text-emerald-300' : 'text-slate-200'}`}>
|
||||
{habit.name}
|
||||
</p>
|
||||
<span className="text-[11px] text-slate-400">{habit.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-orange-500/10 border border-orange-500/30 text-orange-400 text-xs font-bold">
|
||||
<Flame className="w-3.5 h-3.5 fill-orange-400" />
|
||||
<span>{habit.streak}d</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('habits')}
|
||||
className="mt-4 w-full py-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 text-xs font-semibold text-slate-300 transition-colors border border-slate-700/60"
|
||||
>
|
||||
Track & Add Habits
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Today's Timeline & Quick Notes Preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Calendar Events */}
|
||||
<div className="glass-card p-6 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-bold text-slate-100 text-base">Today's Schedule</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('calendar')}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 font-semibold flex items-center gap-1"
|
||||
>
|
||||
Full Calendar <ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{todayEvents.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{todayEvents.map((evt) => (
|
||||
<div
|
||||
key={evt.id}
|
||||
className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 flex items-start gap-3"
|
||||
>
|
||||
<div className="w-2 h-10 rounded-full bg-blue-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-200 truncate">{evt.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{evt.startTime} - {evt.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 py-4 text-center">No more events scheduled for today.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Latest Note Pin */}
|
||||
<div className="glass-card p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-slate-100 text-base">Pinned Insight</h3>
|
||||
<button
|
||||
onClick={() => onNavigate('notes')}
|
||||
className="text-xs text-purple-400 hover:text-purple-300 font-semibold"
|
||||
>
|
||||
All Notes
|
||||
</button>
|
||||
</div>
|
||||
{notes[0] ? (
|
||||
<div className="p-4 rounded-xl bg-purple-950/20 border border-purple-500/20">
|
||||
<p className="text-xs font-bold text-purple-300 mb-1">{notes[0].title}</p>
|
||||
<p className="text-xs text-slate-300 line-clamp-4 leading-relaxed font-mono whitespace-pre-line">
|
||||
{notes[0].content}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">No notes captured yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Wallet,
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Tag,
|
||||
X,
|
||||
PieChart,
|
||||
SlidersHorizontal
|
||||
} from 'lucide-react';
|
||||
import { ExpenseItem } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface FinanceViewProps {
|
||||
expenses: ExpenseItem[];
|
||||
onAddTransaction: (item: Omit<ExpenseItem, 'id'>) => void;
|
||||
onDeleteTransaction: (id: string) => void;
|
||||
}
|
||||
|
||||
export const FinanceView: React.FC<FinanceViewProps> = ({
|
||||
expenses,
|
||||
onAddTransaction,
|
||||
onDeleteTransaction,
|
||||
}) => {
|
||||
const [filterType, setFilterType] = useState<'all' | 'income' | 'expense'>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// New Transaction Form
|
||||
const [title, setTitle] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [type, setType] = useState<'income' | 'expense'>('expense');
|
||||
const [category, setCategory] = useState('Food');
|
||||
const [date, setDate] = useState(getFormattedDate(0));
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const monthlyBudgetLimit = 2500;
|
||||
|
||||
const totalIncome = expenses
|
||||
.filter((e) => e.type === 'income')
|
||||
.reduce((acc, curr) => acc + curr.amount, 0);
|
||||
|
||||
const totalExpense = expenses
|
||||
.filter((e) => e.type === 'expense')
|
||||
.reduce((acc, curr) => acc + curr.amount, 0);
|
||||
|
||||
const netBalance = totalIncome - totalExpense;
|
||||
const budgetUsagePercent = Math.min(Math.round((totalExpense / monthlyBudgetLimit) * 100), 100);
|
||||
|
||||
// Calculate Category Breakdowns for Expenses
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
expenses
|
||||
.filter((e) => e.type === 'expense')
|
||||
.forEach((e) => {
|
||||
categoryTotals[e.category] = (categoryTotals[e.category] || 0) + e.amount;
|
||||
});
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const numAmount = parseFloat(amount);
|
||||
if (!title.trim() || isNaN(numAmount) || numAmount <= 0) return;
|
||||
|
||||
onAddTransaction({
|
||||
title: title.trim(),
|
||||
amount: numAmount,
|
||||
type,
|
||||
category: category.trim() || 'General',
|
||||
date,
|
||||
note: note.trim() || undefined,
|
||||
});
|
||||
|
||||
setTitle('');
|
||||
setAmount('');
|
||||
setNote('');
|
||||
setIsModalOpen(false);
|
||||
playNotificationChime(type === 'income' ? 'streak' : 'click');
|
||||
};
|
||||
|
||||
const filteredList = expenses.filter((e) => {
|
||||
if (filterType === 'all') return true;
|
||||
return e.type === filterType;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Net Savings Card */}
|
||||
<div className="glass-card p-5 border-l-4 border-l-cyan-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Net Balance</span>
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||
<Wallet className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-3xl font-extrabold text-white">
|
||||
${netBalance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Available liquid balance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Income Card */}
|
||||
<div className="glass-card p-5 border-l-4 border-l-emerald-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Total Income</span>
|
||||
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||
<ArrowUpRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-3xl font-extrabold text-emerald-400">
|
||||
+${totalIncome.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Inflow this period</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Expenses Card */}
|
||||
<div className="glass-card p-5 border-l-4 border-l-rose-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Total Expenses</span>
|
||||
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||
<ArrowDownRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-3xl font-extrabold text-rose-400">
|
||||
-${totalExpense.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Outflow this period</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Meter & Category Breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Monthly Budget Bar */}
|
||||
<div className="glass-card p-6 flex flex-col justify-between space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Monthly Budget Tracker</span>
|
||||
<span className="text-xs font-bold text-cyan-400">{budgetUsagePercent}% Used</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-baseline justify-between text-sm mb-2">
|
||||
<span className="text-slate-300 font-semibold">${totalExpense} Spent</span>
|
||||
<span className="text-slate-500">Target: ${monthlyBudgetLimit}</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 rounded-full ${
|
||||
budgetUsagePercent > 90 ? 'bg-rose-500' : budgetUsagePercent > 70 ? 'bg-amber-500' : 'bg-cyan-500'
|
||||
}`}
|
||||
style={{ width: `${budgetUsagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-3 leading-relaxed">
|
||||
You have <strong className="text-slate-200">${Math.max(0, monthlyBudgetLimit - totalExpense)}</strong> remaining in your safe monthly spending threshold.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-teal-500 to-emerald-600 text-slate-950 font-bold text-xs shadow-lg shadow-teal-500/20 hover:scale-102 active:scale-98 transition-all cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Record Transaction</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown Progress Bars */}
|
||||
<div className="glass-card p-6 lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4 text-teal-400" />
|
||||
<span>Expense Categories Distribution</span>
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{Object.keys(categoryTotals).length} categories active</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Object.entries(categoryTotals).map(([cat, amount]) => {
|
||||
const pct = totalExpense > 0 ? Math.round((amount / totalExpense) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div key={cat} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-slate-200 flex items-center gap-1.5">
|
||||
<Tag className="w-3 h-3 text-teal-400" />
|
||||
{cat}
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
${amount.toLocaleString()} <span className="text-teal-400 font-bold">({pct}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800/80">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.keys(categoryTotals).length === 0 && (
|
||||
<p className="text-xs text-slate-500 py-6 text-center">No expense categories to display.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction History Ledger */}
|
||||
<div className="glass-card overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-100">Transaction History</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">All income deposits and expenditure logs</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800 text-xs">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-400 ml-2 mr-1" />
|
||||
{(['all', 'income', 'expense'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilterType(t)}
|
||||
className={`px-3 py-1 rounded-lg font-semibold uppercase tracking-wider text-[10px] transition-colors ${
|
||||
filterType === t
|
||||
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors border border-slate-700"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ledger Rows */}
|
||||
<div className="divide-y divide-slate-800/60">
|
||||
{filteredList.map((item) => {
|
||||
const isInc = item.type === 'income';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-4 sm:px-6 flex items-center justify-between gap-4 hover:bg-slate-900/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center border shrink-0 ${
|
||||
isInc
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-rose-500/10 text-rose-400 border-rose-500/30'
|
||||
}`}>
|
||||
{isInc ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-200 truncate">{item.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5">
|
||||
<span className="px-2 py-0.2 rounded-full bg-slate-900 border border-slate-800 text-[10px]">
|
||||
{item.category}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{item.date}</span>
|
||||
{item.note && (
|
||||
<>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline truncate text-slate-500 italic">{item.note}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className={`text-base font-bold text-right ${isInc ? 'text-emerald-400' : 'text-slate-200'}`}>
|
||||
{isInc ? '+' : '-'}${item.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDeleteTransaction(item.id)}
|
||||
className="opacity-60 group-hover:opacity-100 text-slate-500 hover:text-red-400 p-1 rounded transition-all"
|
||||
title="Delete Transaction"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredList.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-500 text-xs">
|
||||
No transactions recorded in this view.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record Transaction Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-md rounded-2xl bg-[#0e1424] border border-teal-500/30 p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-teal-400" />
|
||||
Record Transaction
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{/* Type Switcher */}
|
||||
<div className="grid grid-cols-2 gap-2 p-1 bg-slate-900 border border-slate-800 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('expense')}
|
||||
className={`py-2 text-xs font-bold rounded-lg transition-colors ${
|
||||
type === 'expense'
|
||||
? 'bg-rose-500 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Expense (-)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('income')}
|
||||
className={`py-2 text-xs font-bold rounded-lg transition-colors ${
|
||||
type === 'income'
|
||||
? 'bg-emerald-500 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Income (+)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Title / Description *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Grocery run, Freelance payment"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Amount ($) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Food, Health, Subscriptions"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Optional Note
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Receipt #12"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-teal-500 to-emerald-600 text-slate-950 text-xs font-bold shadow-md shadow-teal-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
Save Entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Flame,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
Trophy,
|
||||
Sparkles,
|
||||
X,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
import { Habit } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface HabitsViewProps {
|
||||
habits: Habit[];
|
||||
onToggleHabit: (habitId: string, dateStr?: string) => void;
|
||||
onAddHabit: (habit: Omit<Habit, 'id' | 'streak' | 'bestStreak' | 'completedDates'>) => void;
|
||||
onDeleteHabit: (habitId: string) => void;
|
||||
}
|
||||
|
||||
export const HabitsView: React.FC<HabitsViewProps> = ({
|
||||
habits,
|
||||
onToggleHabit,
|
||||
onAddHabit,
|
||||
onDeleteHabit,
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('Productivity');
|
||||
const [color, setColor] = useState('#06b6d4');
|
||||
|
||||
const todayStr = getFormattedDate(0);
|
||||
|
||||
// Generate last 7 days array
|
||||
const last7Days = [-6, -5, -4, -3, -2, -1, 0].map((offset) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dayName = d.toLocaleDateString([], { weekday: 'narrow' });
|
||||
const dayNum = d.getDate();
|
||||
return { offset, dateStr, dayName, dayNum, isToday: offset === 0 };
|
||||
});
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
onAddHabit({
|
||||
name: name.trim(),
|
||||
category: category.trim() || 'General',
|
||||
frequency: 'daily',
|
||||
color,
|
||||
});
|
||||
|
||||
setName('');
|
||||
setIsModalOpen(false);
|
||||
playNotificationChime('streak');
|
||||
};
|
||||
|
||||
const completedTodayCount = habits.filter(h => h.completedDates.includes(todayStr)).length;
|
||||
const completionRate = habits.length > 0 ? Math.round((completedTodayCount / habits.length) * 100) : 0;
|
||||
const bestOverallStreak = Math.max(...habits.map(h => h.bestStreak), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Banner Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="glass-card p-5 border-l-4 border-l-orange-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Today's Score</span>
|
||||
<Flame className="w-5 h-5 text-orange-400 fill-orange-400/20" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{completedTodayCount} / {habits.length}</span>
|
||||
<span className="text-xs text-orange-400 font-bold">({completionRate}%)</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Keep the momentum alive!</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-5 border-l-4 border-l-cyan-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">All-Time Best Streak</span>
|
||||
<Trophy className="w-5 h-5 text-cyan-400" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{bestOverallStreak}</span>
|
||||
<span className="text-xs text-slate-400">days record</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Consistency builds character</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-5 border-l-4 border-l-emerald-500 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Active Routines</span>
|
||||
<Target className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-3xl font-extrabold text-white">{habits.length}</span>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/30 text-xs font-bold transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Routine</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Habits Matrix */}
|
||||
<div className="glass-card overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-100 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-orange-400" />
|
||||
<span>7-Day Consistency Matrix</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Click any circle to toggle habit completion</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-orange-500 to-amber-500 text-slate-950 text-xs font-bold shadow-lg shadow-orange-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>New Habit</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matrix Header Row */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[640px]">
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-slate-900/60 border-b border-slate-800/80 text-xs font-semibold text-slate-400 uppercase">
|
||||
<div className="col-span-5">Habit Routine</div>
|
||||
<div className="col-span-2 text-center">Streak</div>
|
||||
<div className="col-span-4 grid grid-cols-7 gap-1 text-center">
|
||||
{last7Days.map(d => (
|
||||
<div key={d.dateStr} className={`${d.isToday ? 'text-orange-400 font-bold' : ''}`}>
|
||||
<div>{d.dayName}</div>
|
||||
<div className="text-[10px] text-slate-400">{d.dayNum}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-1 text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Habit Rows */}
|
||||
<div className="divide-y divide-slate-800/60">
|
||||
{habits.map((habit) => {
|
||||
const isTodayDone = habit.completedDates.includes(todayStr);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={habit.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-slate-900/40 transition-colors"
|
||||
>
|
||||
{/* Habit Title & Quick Today Check */}
|
||||
<div className="col-span-5 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleHabit(habit.id, todayStr);
|
||||
playNotificationChime(isTodayDone ? 'click' : 'streak');
|
||||
}}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center border transition-all cursor-pointer ${
|
||||
isTodayDone
|
||||
? 'bg-emerald-500 border-emerald-400 text-slate-950 shadow-md shadow-emerald-500/30'
|
||||
: 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
title={isTodayDone ? "Completed today!" : "Click to check off for today"}
|
||||
>
|
||||
<CheckCircle2 className={`w-4 h-4 ${isTodayDone ? 'stroke-[2.5]' : 'opacity-30'}`} />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-semibold truncate ${isTodayDone ? 'text-emerald-300' : 'text-slate-200'}`}>
|
||||
{habit.name}
|
||||
</p>
|
||||
<span className="text-[11px] text-slate-400">{habit.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streak Badge */}
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-orange-500/10 border border-orange-500/30 text-orange-400 text-xs font-extrabold shadow-sm">
|
||||
<Flame className="w-3.5 h-3.5 fill-orange-400" />
|
||||
<span>{habit.streak} days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7-Day History Bubbles */}
|
||||
<div className="col-span-4 grid grid-cols-7 gap-1 place-items-center">
|
||||
{last7Days.map((d) => {
|
||||
const isDone = habit.completedDates.includes(d.dateStr);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={d.dateStr}
|
||||
onClick={() => {
|
||||
onToggleHabit(habit.id, d.dateStr);
|
||||
playNotificationChime(isDone ? 'click' : 'complete');
|
||||
}}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold transition-all cursor-pointer ${
|
||||
isDone
|
||||
? 'bg-orange-500/90 text-slate-950 shadow-sm shadow-orange-500/40 hover:scale-110'
|
||||
: d.isToday
|
||||
? 'bg-slate-900 border-2 border-dashed border-orange-500/50 text-slate-400 hover:border-orange-500'
|
||||
: 'bg-slate-900/60 border border-slate-800 text-slate-400 hover:border-slate-700'
|
||||
}`}
|
||||
title={`${d.dateStr}: ${isDone ? 'Completed' : 'Missed'}`}
|
||||
>
|
||||
{isDone ? '✓' : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 text-right">
|
||||
<button
|
||||
onClick={() => onDeleteHabit(habit.id)}
|
||||
className="text-slate-400 hover:text-red-400 transition-colors p-1 rounded"
|
||||
title="Delete Habit"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Habit Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-md rounded-2xl bg-[#0e1424] border border-orange-500/30 p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
Add Daily Routine
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Habit Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Read 20 Pages, Meditate, Hit 10k Steps"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Health, Code, Mindset"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Accent Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{['#06b6d4', '#10b981', '#f59e0b', '#a855f7', '#ec4899'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-transform ${
|
||||
color === c ? 'scale-125 border-white' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-orange-500 to-amber-500 text-slate-950 text-xs font-bold shadow-md shadow-orange-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
Start Tracking
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Tag,
|
||||
Pin,
|
||||
Smile,
|
||||
X,
|
||||
Sparkles,
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import { Note } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface NotesViewProps {
|
||||
notes: Note[];
|
||||
onAddNote: (note: Omit<Note, 'id' | 'updatedAt'>) => void;
|
||||
onUpdateNote: (id: string, updates: Partial<Note>) => void;
|
||||
onDeleteNote: (id: string) => void;
|
||||
showAddModalDefault?: boolean;
|
||||
}
|
||||
|
||||
export const NotesView: React.FC<NotesViewProps> = ({
|
||||
notes,
|
||||
onAddNote,
|
||||
onUpdateNote,
|
||||
onDeleteNote,
|
||||
showAddModalDefault = false,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(showAddModalDefault);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [category, setCategory] = useState('Tech');
|
||||
const [mood, setMood] = useState<1 | 2 | 3 | 4 | 5>(4);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
|
||||
const moodEmojis: Record<number, { emoji: string; label: string }> = {
|
||||
1: { emoji: '😫', label: 'Stressed' },
|
||||
2: { emoji: '😴', label: 'Tired' },
|
||||
3: { emoji: '😐', label: 'Neutral' },
|
||||
4: { emoji: '😊', label: 'Energized' },
|
||||
5: { emoji: '🚀', label: 'Unstoppable' },
|
||||
};
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
onAddNote({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
tags,
|
||||
category: category.trim() || 'General',
|
||||
mood,
|
||||
pinned,
|
||||
});
|
||||
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setTagsInput('');
|
||||
setIsModalOpen(false);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(notes.map((n) => n.category)))];
|
||||
|
||||
const filteredNotes = notes.filter((n) => {
|
||||
const matchesSearch =
|
||||
n.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
n.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
n.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesCat = selectedCategory === 'all' || n.category === selectedCategory;
|
||||
return matchesSearch && matchesCat;
|
||||
});
|
||||
|
||||
// Sort pinned first
|
||||
const sortedNotes = [...filteredNotes].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Bar: Search, Category Filter, New Note Button */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes or tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Pills */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto w-full sm:w-auto pb-1 sm:pb-0">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-semibold capitalize transition-all shrink-0 ${
|
||||
selectedCategory === cat
|
||||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/40'
|
||||
: 'bg-slate-900 text-slate-400 border border-slate-800 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-bold shadow-lg shadow-purple-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer shrink-0 ml-auto sm:ml-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Note</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedNotes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`glass-card p-6 flex flex-col justify-between group transition-all relative ${
|
||||
note.pinned ? 'border-purple-500/40 shadow-purple-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{/* Header: Category, Mood, Pin, Delete */}
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider px-2.5 py-0.5 rounded-md bg-purple-500/10 text-purple-300 border border-purple-500/30">
|
||||
{note.category}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{note.mood && (
|
||||
<span
|
||||
className="text-sm bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800"
|
||||
title={`Mood: ${moodEmojis[note.mood]?.label}`}
|
||||
>
|
||||
{moodEmojis[note.mood]?.emoji}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onUpdateNote(note.id, { pinned: !note.pinned })}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
note.pinned ? 'text-purple-400' : 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
title={note.pinned ? "Unpin note" : "Pin note"}
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onDeleteNote(note.id)}
|
||||
className="p-1 text-slate-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete note"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Title */}
|
||||
<h4 className="text-base font-bold text-slate-100 mb-2">{note.title}</h4>
|
||||
|
||||
{/* Note Content */}
|
||||
<div className="text-xs text-slate-300 leading-relaxed font-mono whitespace-pre-wrap bg-slate-950/40 p-3 rounded-xl border border-slate-900 max-h-48 overflow-y-auto">
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Tags & Date */}
|
||||
<div className="mt-4 pt-3 border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{note.tags.map((t) => (
|
||||
<span key={t} className="inline-flex items-center gap-1 text-[10px] text-slate-400 bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800">
|
||||
<Tag className="w-2.5 h-2.5 text-purple-400" />
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span>{note.updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedNotes.length === 0 && (
|
||||
<div className="col-span-full py-16 text-center text-slate-500 space-y-2">
|
||||
<BookOpen className="w-8 h-8 mx-auto text-slate-600" />
|
||||
<p className="text-sm">No notes found matching your criteria.</p>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-purple-400 hover:text-purple-300 text-xs font-semibold"
|
||||
>
|
||||
Write your first thought
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Note Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg rounded-2xl bg-[#0e1424] border border-purple-500/30 p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
Capture Note & Reflection
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Architectural Principles, Today's Reflection"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Content (Markdown Supported) *
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
required
|
||||
placeholder="Write your thoughts, code snippets, learnings, or daily reflection..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tech, Mindset, Life"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="React, Vision, 2026"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mood Selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Daily Mood Pulse
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{([1, 2, 3, 4, 5] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMood(m)}
|
||||
className={`p-2 rounded-xl border flex flex-col items-center gap-1 transition-all ${
|
||||
mood === m
|
||||
? 'bg-purple-500/20 border-purple-500 shadow-md shadow-purple-500/20'
|
||||
: 'bg-slate-900 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{moodEmojis[m].emoji}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">{moodEmojis[m].label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="pinnedCheck"
|
||||
checked={pinned}
|
||||
onChange={(e) => setPinned(e.target.checked)}
|
||||
className="rounded border-slate-700 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="pinnedCheck" className="text-xs text-slate-300 cursor-pointer">
|
||||
Pin to top of list
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-bold shadow-md shadow-purple-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
Flame,
|
||||
Brain,
|
||||
Coffee,
|
||||
CheckCircle2,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Task } from '@/lib/types';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface PomodoroViewProps {
|
||||
tasks: Task[];
|
||||
onCompleteSession?: () => void;
|
||||
}
|
||||
|
||||
type TimerMode = 'focus' | 'shortBreak' | 'longBreak';
|
||||
|
||||
const MODE_CONFIG: Record<TimerMode, { label: string; duration: number; icon: typeof Brain; color: string; ringColor: string }> = {
|
||||
focus: {
|
||||
label: 'Focus Session',
|
||||
duration: 25 * 60,
|
||||
icon: Brain,
|
||||
color: 'text-amber-400 bg-amber-500/10 border-amber-500/30',
|
||||
ringColor: '#f59e0b',
|
||||
},
|
||||
shortBreak: {
|
||||
label: 'Short Rest',
|
||||
duration: 5 * 60,
|
||||
icon: Coffee,
|
||||
color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
|
||||
ringColor: '#06b6d4',
|
||||
},
|
||||
longBreak: {
|
||||
label: 'Long Recovery',
|
||||
duration: 15 * 60,
|
||||
icon: Coffee,
|
||||
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30',
|
||||
ringColor: '#10b981',
|
||||
},
|
||||
};
|
||||
|
||||
export const PomodoroView: React.FC<PomodoroViewProps> = ({ tasks }) => {
|
||||
const [mode, setMode] = useState<TimerMode>('focus');
|
||||
const [timeLeft, setTimeLeft] = useState<number>(MODE_CONFIG.focus.duration);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [completedSessions, setCompletedSessions] = useState<number>(3);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string>('');
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current!);
|
||||
setIsActive(false);
|
||||
playNotificationChime('complete');
|
||||
if (mode === 'focus') {
|
||||
setCompletedSessions((c) => c + 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [isActive, mode]);
|
||||
|
||||
const switchMode = (newMode: TimerMode) => {
|
||||
setIsActive(false);
|
||||
setMode(newMode);
|
||||
setTimeLeft(MODE_CONFIG[newMode].duration);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const handleToggleTimer = () => {
|
||||
setIsActive(!isActive);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsActive(false);
|
||||
setTimeLeft(MODE_CONFIG[mode].duration);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setIsActive(false);
|
||||
if (mode === 'focus') {
|
||||
switchMode('shortBreak');
|
||||
} else {
|
||||
switchMode('focus');
|
||||
}
|
||||
};
|
||||
|
||||
const minutes = Math.floor(timeLeft / 60);
|
||||
const seconds = timeLeft % 60;
|
||||
const timeFormatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
const currentDuration = MODE_CONFIG[mode].duration;
|
||||
const progressPercent = ((currentDuration - timeLeft) / currentDuration) * 100;
|
||||
|
||||
// SVG ring properties
|
||||
const radius = 130;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (progressPercent / 100) * circumference;
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === selectedTaskId);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Mode Switcher */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center p-1.5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
{(['focus', 'shortBreak', 'longBreak'] as const).map((m) => {
|
||||
const isSelected = mode === m;
|
||||
const Icon = MODE_CONFIG[m].icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => switchMode(m)}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? MODE_CONFIG[m].color
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{MODE_CONFIG[m].label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Circular Countdown Card */}
|
||||
<div className="glass-card p-8 md:p-12 flex flex-col items-center justify-center relative overflow-hidden border-slate-800/80 shadow-2xl">
|
||||
{/* Ambient background glow */}
|
||||
<div
|
||||
className="absolute w-72 h-72 rounded-full blur-[100px] opacity-20 pointer-events-none transition-all duration-700"
|
||||
style={{ backgroundColor: MODE_CONFIG[mode].ringColor }}
|
||||
/>
|
||||
|
||||
{/* Circular SVG Timer */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="w-72 h-72 md:w-80 md:h-80 -rotate-90 transform">
|
||||
{/* Background Track */}
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r={radius}
|
||||
className="text-slate-800/60 stroke-current"
|
||||
strokeWidth="10"
|
||||
fill="transparent"
|
||||
/>
|
||||
{/* Animated Progress Ring */}
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r={radius}
|
||||
stroke={MODE_CONFIG[mode].ringColor}
|
||||
strokeWidth="12"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
className="transition-all duration-500"
|
||||
style={{
|
||||
filter: `drop-shadow(0 0 10px ${MODE_CONFIG[mode].ringColor}80)`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Time Display Inside Circle */}
|
||||
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||
<span className="font-mono text-5xl md:text-6xl font-extrabold text-white tracking-tighter">
|
||||
{timeFormatted}
|
||||
</span>
|
||||
<span className="text-xs uppercase font-bold tracking-widest text-slate-400 mt-2">
|
||||
{isActive ? 'Session In Progress' : 'Paused'}
|
||||
</span>
|
||||
{currentTask && (
|
||||
<span className="mt-3 px-3 py-1 rounded-full bg-slate-900 border border-slate-800 text-[11px] text-cyan-400 font-medium max-w-[200px] truncate">
|
||||
🎯 {currentTask.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="mt-8 flex items-center gap-4 z-10">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-3.5 rounded-2xl bg-slate-900/80 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800 transition-all cursor-pointer shadow-md"
|
||||
title="Reset timer"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleToggleTimer}
|
||||
className={`flex items-center gap-2.5 px-8 py-4 rounded-2xl text-slate-950 font-extrabold text-base shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer`}
|
||||
style={{
|
||||
backgroundColor: MODE_CONFIG[mode].ringColor,
|
||||
boxShadow: `0 10px 25px -5px ${MODE_CONFIG[mode].ringColor}60`,
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<>
|
||||
<Pause className="w-5 h-5 fill-slate-950" />
|
||||
<span>Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5 fill-slate-950" />
|
||||
<span>Start Focus</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="p-3.5 rounded-2xl bg-slate-900/80 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800 transition-all cursor-pointer shadow-md"
|
||||
title="Skip to next session"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Session Target & Task Pairing */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Daily Focus Sessions Card */}
|
||||
<div className="glass-card p-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-400">
|
||||
<Flame className="w-6 h-6 fill-amber-400/20" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Sessions Completed
|
||||
</span>
|
||||
<div className="text-2xl font-extrabold text-white mt-0.5">
|
||||
{completedSessions} / 6 Sessions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-bold text-amber-400">{completedSessions * 25} Mins</span>
|
||||
<p className="text-[11px] text-slate-400">Deep Work Today</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Attachment Picker */}
|
||||
<div className="glass-card p-5 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Pair with Task
|
||||
</span>
|
||||
{selectedTaskId && (
|
||||
<button
|
||||
onClick={() => setSelectedTaskId('')}
|
||||
className="text-[11px] text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={selectedTaskId}
|
||||
onChange={(e) => setSelectedTaskId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
<option value="">No task linked (Freestyle Focus)</option>
|
||||
{tasks
|
||||
.filter((t) => t.status !== 'completed')
|
||||
.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
[{t.priority.toUpperCase()}] {t.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Tag,
|
||||
Columns,
|
||||
List,
|
||||
X,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { Task, Priority, TaskStatus } from '@/lib/types';
|
||||
import { getFormattedDate } from '@/lib/initialData';
|
||||
import { playNotificationChime } from '@/lib/audio';
|
||||
|
||||
interface TasksViewProps {
|
||||
tasks: Task[];
|
||||
onAddTask: (task: Omit<Task, 'id' | 'createdAt'>) => void;
|
||||
onUpdateTask: (id: string, updates: Partial<Task>) => void;
|
||||
onDeleteTask: (id: string) => void;
|
||||
showAddModalDefault?: boolean;
|
||||
}
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
tasks,
|
||||
onAddTask,
|
||||
onUpdateTask,
|
||||
onDeleteTask,
|
||||
showAddModalDefault = false,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPriority, setSelectedPriority] = useState<string>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(showAddModalDefault);
|
||||
|
||||
// New task form state
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newPriority, setNewPriority] = useState<Priority>('medium');
|
||||
const [newCategory, setNewCategory] = useState('General');
|
||||
const [newDueDate, setNewDueDate] = useState(getFormattedDate(1));
|
||||
|
||||
const handleCreateTask = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
|
||||
onAddTask({
|
||||
title: newTitle.trim(),
|
||||
description: newDesc.trim() || undefined,
|
||||
status: 'todo',
|
||||
priority: newPriority,
|
||||
category: newCategory.trim() || 'General',
|
||||
dueDate: newDueDate || undefined,
|
||||
});
|
||||
|
||||
setNewTitle('');
|
||||
setNewDesc('');
|
||||
setIsModalOpen(false);
|
||||
playNotificationChime('click');
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) => {
|
||||
const matchesSearch =
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(task.description && task.description.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesPriority = selectedPriority === 'all' || task.priority === selectedPriority;
|
||||
return matchesSearch && matchesPriority;
|
||||
});
|
||||
|
||||
const columns: { id: TaskStatus; label: string; color: string; count: number }[] = [
|
||||
{
|
||||
id: 'todo',
|
||||
label: 'To Do',
|
||||
color: 'border-cyan-500/40 text-cyan-400 bg-cyan-500/10',
|
||||
count: filteredTasks.filter(t => t.status === 'todo').length
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
label: 'In Progress',
|
||||
color: 'border-amber-500/40 text-amber-400 bg-amber-500/10',
|
||||
count: filteredTasks.filter(t => t.status === 'in-progress').length
|
||||
},
|
||||
{
|
||||
id: 'completed',
|
||||
label: 'Completed',
|
||||
color: 'border-emerald-500/40 text-emerald-400 bg-emerald-500/10',
|
||||
count: filteredTasks.filter(t => t.status === 'completed').length
|
||||
},
|
||||
];
|
||||
|
||||
const getPriorityBadge = (p: Priority) => {
|
||||
switch (p) {
|
||||
case 'urgent':
|
||||
return 'bg-red-500/10 text-red-400 border-red-500/30';
|
||||
case 'high':
|
||||
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
|
||||
case 'medium':
|
||||
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
|
||||
case 'low':
|
||||
return 'bg-slate-500/10 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Bar: Controls, Search, New Task Button */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
{/* Search & Priority Filter */}
|
||||
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedPriority}
|
||||
onChange={(e) => setSelectedPriority(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-300 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View Toggle & Add Button */}
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
||||
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||||
<button
|
||||
onClick={() => setViewMode('kanban')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
viewMode === 'kanban' ? 'bg-slate-800 text-cyan-400' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
title="Kanban View"
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
viewMode === 'list' ? 'bg-slate-800 text-cyan-400' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
title="List View"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-sm font-semibold shadow-lg shadow-cyan-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board View */}
|
||||
{viewMode === 'kanban' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{columns.map((col) => {
|
||||
const colTasks = filteredTasks.filter((t) => t.status === col.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex flex-col rounded-2xl bg-[#0e1424]/70 border border-slate-800/80 p-4 min-h-[480px]"
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800/80 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold border ${col.color}`}>
|
||||
{col.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-500 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Task Cards */}
|
||||
<div className="space-y-3 flex-1 overflow-y-auto">
|
||||
{colTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group p-4 rounded-xl bg-slate-900/90 border border-slate-800/90 hover:border-slate-700 transition-all shadow-md relative"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-md border ${getPriorityBadge(task.priority)}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
|
||||
{/* Status Change Quick Dropdown */}
|
||||
<div className="flex items-center gap-1 opacity-80 group-hover:opacity-100">
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) => {
|
||||
onUpdateTask(task.id, { status: e.target.value as TaskStatus });
|
||||
if (e.target.value === 'completed') playNotificationChime('complete');
|
||||
}}
|
||||
className="text-[11px] bg-slate-950 border border-slate-800 text-slate-400 rounded px-1.5 py-0.5 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="todo">To Do</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="completed">Done</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onDeleteTask(task.id)}
|
||||
className="p-1 text-slate-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete Task"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="mt-2.5 text-sm font-semibold text-slate-100 leading-snug">
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
{task.description && (
|
||||
<p className="mt-1 text-xs text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-800/60 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1 text-slate-400">
|
||||
<Tag className="w-3 h-3 text-cyan-400" />
|
||||
<span>{task.category}</span>
|
||||
</div>
|
||||
|
||||
{task.dueDate && (
|
||||
<div className="flex items-center gap-1 text-slate-400">
|
||||
<Calendar className="w-3 h-3 text-amber-400" />
|
||||
<span>{task.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{colTasks.length === 0 && (
|
||||
<div className="h-32 flex flex-col items-center justify-center text-slate-600 text-xs border border-dashed border-slate-800/80 rounded-xl">
|
||||
No tasks in {col.label.toLowerCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="rounded-2xl bg-[#0e1424]/70 border border-slate-800/80 overflow-hidden">
|
||||
<div className="divide-y divide-slate-800/70">
|
||||
{filteredTasks.map((task) => {
|
||||
const isDone = task.status === 'completed';
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 flex items-center justify-between gap-4 hover:bg-slate-900/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newStatus: TaskStatus = isDone ? 'todo' : 'completed';
|
||||
onUpdateTask(task.id, { status: newStatus });
|
||||
if (newStatus === 'completed') playNotificationChime('complete');
|
||||
}}
|
||||
className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
|
||||
isDone
|
||||
? 'bg-emerald-500 border-emerald-400 text-black'
|
||||
: 'border-slate-700 hover:border-cyan-400'
|
||||
}`}
|
||||
>
|
||||
{isDone && <span className="text-xs font-black">✓</span>}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-semibold truncate ${isDone ? 'line-through text-slate-500' : 'text-slate-200'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-slate-400 truncate">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded-full border ${getPriorityBadge(task.priority)}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 bg-slate-900 px-2 py-0.5 rounded border border-slate-800">
|
||||
{task.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onDeleteTask(task.id)}
|
||||
className="text-slate-500 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg rounded-2xl bg-[#0e1424] border border-cyan-500/30 p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-cyan-400" />
|
||||
Create New Task
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateTask} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Task Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Master React Hooks"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Briefly describe what needs to be done..."
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-cyan-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={newPriority}
|
||||
onChange={(e) => setNewPriority(e.target.value as Priority)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="General"
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDueDate}
|
||||
onChange={(e) => setNewDueDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-xs font-bold shadow-md shadow-cyan-500/20 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
Save Task
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className={cn(
|
||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-full glass hover:bg-white/40 dark:hover:bg-black/40 transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Web Audio API chime generator (no external audio assets required)
|
||||
export const playNotificationChime = (type: 'complete' | 'click' | 'streak' = 'complete') => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
const ctx = new AudioContextClass();
|
||||
|
||||
if (type === 'complete') {
|
||||
// Pleasant two-tone chime
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'sine';
|
||||
osc1.frequency.setValueAtTime(587.33, now); // D5
|
||||
gain1.gain.setValueAtTime(0.3, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.8);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.setValueAtTime(880, now + 0.15); // A5
|
||||
gain2.gain.setValueAtTime(0.3, now + 0.15);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 1.2);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.15);
|
||||
osc2.stop(now + 1.2);
|
||||
} else if (type === 'streak') {
|
||||
// Ascending triumphant arpeggio
|
||||
const notes = [440, 554.37, 659.25, 880];
|
||||
notes.forEach((freq, idx) => {
|
||||
const now = ctx.currentTime + idx * 0.08;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
gain.gain.setValueAtTime(0.2, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.3);
|
||||
});
|
||||
} else {
|
||||
// Subtle click
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(800, now);
|
||||
gain.gain.setValueAtTime(0.05, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.05);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Web Audio not allowed or failed:', err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import { Task, Habit, ExpenseItem, CalendarEvent, Note, WorkoutSession, DailyNutrition, MuscleRecovery } from './types';
|
||||
|
||||
// Helper to get formatted date string YYYY-MM-DD
|
||||
export const getFormattedDate = (offsetDays: number = 0): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offsetDays);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
export const INITIAL_TASKS: Task[] = [
|
||||
{
|
||||
id: 't-1',
|
||||
title: 'Master React & Next.js Core Concepts',
|
||||
description: 'Understand component lifecycle, hooks (useState, useEffect), and props data flow.',
|
||||
status: 'in-progress',
|
||||
priority: 'urgent',
|
||||
category: 'Learning',
|
||||
dueDate: getFormattedDate(1),
|
||||
createdAt: getFormattedDate(-2),
|
||||
},
|
||||
{
|
||||
id: 't-2',
|
||||
title: 'Launch Parth\'s Life Manager Dashboard',
|
||||
description: 'Set up dark mode, modular dashboard, and local storage persistence.',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
category: 'Projects',
|
||||
dueDate: getFormattedDate(0),
|
||||
createdAt: getFormattedDate(-1),
|
||||
},
|
||||
{
|
||||
id: 't-3',
|
||||
title: 'Plan Weekly Fitness Schedule',
|
||||
description: 'Schedule 3 gym workout days and 2 cardio/running sessions.',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
category: 'Health',
|
||||
dueDate: getFormattedDate(3),
|
||||
createdAt: getFormattedDate(0),
|
||||
},
|
||||
{
|
||||
id: 't-4',
|
||||
title: 'Review Monthly Budget & Subscriptions',
|
||||
description: 'Check recurring payments and allocate monthly investments.',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
category: 'Finance',
|
||||
dueDate: getFormattedDate(5),
|
||||
createdAt: getFormattedDate(0),
|
||||
},
|
||||
{
|
||||
id: 't-5',
|
||||
title: 'Set up Project Repository & Environment',
|
||||
description: 'Install dependencies, configure Tailwind, and test local development server.',
|
||||
status: 'completed',
|
||||
priority: 'high',
|
||||
category: 'Projects',
|
||||
dueDate: getFormattedDate(-1),
|
||||
createdAt: getFormattedDate(-3),
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_HABITS: Habit[] = [
|
||||
{
|
||||
id: 'h-1',
|
||||
name: 'Code for 45 Mins',
|
||||
category: 'Skill Building',
|
||||
frequency: 'daily',
|
||||
streak: 6,
|
||||
bestStreak: 14,
|
||||
completedDates: [
|
||||
getFormattedDate(-5),
|
||||
getFormattedDate(-4),
|
||||
getFormattedDate(-3),
|
||||
getFormattedDate(-2),
|
||||
getFormattedDate(-1),
|
||||
getFormattedDate(0),
|
||||
],
|
||||
color: '#06b6d4', // cyan
|
||||
},
|
||||
{
|
||||
id: 'h-2',
|
||||
name: 'Drink 2.5L Water',
|
||||
category: 'Health',
|
||||
frequency: 'daily',
|
||||
streak: 9,
|
||||
bestStreak: 12,
|
||||
completedDates: [
|
||||
getFormattedDate(-3),
|
||||
getFormattedDate(-2),
|
||||
getFormattedDate(-1),
|
||||
getFormattedDate(0),
|
||||
],
|
||||
color: '#10b981', // emerald
|
||||
},
|
||||
{
|
||||
id: 'h-3',
|
||||
name: 'Read 20 Pages',
|
||||
category: 'Mindset',
|
||||
frequency: 'daily',
|
||||
streak: 4,
|
||||
bestStreak: 8,
|
||||
completedDates: [
|
||||
getFormattedDate(-3),
|
||||
getFormattedDate(-2),
|
||||
getFormattedDate(-1),
|
||||
],
|
||||
color: '#a855f7', // purple
|
||||
},
|
||||
{
|
||||
id: 'h-4',
|
||||
name: 'Morning Workout & Stretch',
|
||||
category: 'Fitness',
|
||||
frequency: 'daily',
|
||||
streak: 2,
|
||||
bestStreak: 10,
|
||||
completedDates: [
|
||||
getFormattedDate(-1),
|
||||
getFormattedDate(0),
|
||||
],
|
||||
color: '#f59e0b', // amber
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_EXPENSES: ExpenseItem[] = [
|
||||
{
|
||||
id: 'e-1',
|
||||
title: 'Software Dev Stipend / Salary',
|
||||
amount: 3200,
|
||||
type: 'income',
|
||||
category: 'Income',
|
||||
date: getFormattedDate(-5),
|
||||
note: 'Monthly salary allocation',
|
||||
},
|
||||
{
|
||||
id: 'e-2',
|
||||
title: 'Tech Gadgets & Desk Setup',
|
||||
amount: 145,
|
||||
type: 'expense',
|
||||
category: 'Equipment',
|
||||
date: getFormattedDate(-3),
|
||||
note: 'Mechanical keyboard and cable organizer',
|
||||
},
|
||||
{
|
||||
id: 'e-3',
|
||||
title: 'Cloud & AI Subscriptions',
|
||||
amount: 35,
|
||||
type: 'expense',
|
||||
category: 'Subscriptions',
|
||||
date: getFormattedDate(-2),
|
||||
note: 'API and hosting subscriptions',
|
||||
},
|
||||
{
|
||||
id: 'e-4',
|
||||
title: 'Healthy Groceries & Nutrition',
|
||||
amount: 85,
|
||||
type: 'expense',
|
||||
category: 'Food',
|
||||
date: getFormattedDate(-1),
|
||||
note: 'Weekly essentials and fresh produce',
|
||||
},
|
||||
{
|
||||
id: 'e-5',
|
||||
title: 'Gym Membership',
|
||||
amount: 45,
|
||||
type: 'expense',
|
||||
category: 'Health',
|
||||
date: getFormattedDate(-8),
|
||||
note: 'Monthly access pass',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_EVENTS: CalendarEvent[] = [
|
||||
{
|
||||
id: 'ev-1',
|
||||
title: 'React Architecture Planning',
|
||||
date: getFormattedDate(0),
|
||||
startTime: '10:00',
|
||||
endTime: '11:30',
|
||||
category: 'work',
|
||||
description: 'Define component tree and state management strategy.',
|
||||
},
|
||||
{
|
||||
id: 'ev-2',
|
||||
title: 'Focus Coding Session (Deep Work)',
|
||||
date: getFormattedDate(0),
|
||||
startTime: '14:00',
|
||||
endTime: '16:00',
|
||||
category: 'study',
|
||||
description: 'Implement Kanban drag-drop and Pomodoro integration.',
|
||||
},
|
||||
{
|
||||
id: 'ev-3',
|
||||
title: 'Evening Gym & Recovery',
|
||||
date: getFormattedDate(1),
|
||||
startTime: '18:30',
|
||||
endTime: '19:45',
|
||||
category: 'health',
|
||||
description: 'Full body resistance workout and mobility work.',
|
||||
},
|
||||
{
|
||||
id: 'ev-4',
|
||||
title: 'Weekend Project Demo & Review',
|
||||
date: getFormattedDate(4),
|
||||
startTime: '15:00',
|
||||
endTime: '16:30',
|
||||
category: 'personal',
|
||||
description: 'Showcase Parth\'s Life Manager to peers.',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_NOTES: Note[] = [
|
||||
{
|
||||
id: 'n-1',
|
||||
title: 'React Mindset & Mental Models',
|
||||
content: `### Why React Clicked for Me
|
||||
1. **Component-driven**: Everything is isolated and reusable.
|
||||
2. **One-way data flow**: Props go down, events bubble up.
|
||||
3. **State drives the UI**: Change state, and the UI responds automatically!
|
||||
|
||||
Key quote: *"Programs must be written for people to read, and only incidentally for machines to execute."*`,
|
||||
tags: ['React', 'Tech', 'Learning'],
|
||||
category: 'Tech',
|
||||
mood: 5,
|
||||
updatedAt: getFormattedDate(0),
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
id: 'n-2',
|
||||
title: 'Goals for Parth\'s Life Manager',
|
||||
content: `Build an app that feels snappy, looks like a futuristic sci-fi terminal, and actually helps me stay consistent with coding, fitness, and finance every single day.`,
|
||||
tags: ['Goals', 'Life', 'Vision'],
|
||||
category: 'Life',
|
||||
mood: 4,
|
||||
updatedAt: getFormattedDate(-1),
|
||||
pinned: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_NUTRITION: DailyNutrition = {
|
||||
date: getFormattedDate(0),
|
||||
calories: 2380,
|
||||
calorieTarget: 2500,
|
||||
protein: 168, // grams (~2.1g per kg bodyweight)
|
||||
proteinTarget: 165,
|
||||
carbs: 270,
|
||||
carbsTarget: 290,
|
||||
fats: 62,
|
||||
fatsTarget: 65,
|
||||
waterLiters: 2.75,
|
||||
creatineTaken: true,
|
||||
bodyweightKg: 78.5,
|
||||
};
|
||||
|
||||
export const INITIAL_WORKOUTS: WorkoutSession[] = [
|
||||
{
|
||||
id: 'w-1',
|
||||
name: 'Push Hypertrophy (Chest & Delts)',
|
||||
date: getFormattedDate(0),
|
||||
muscleGroups: ['Chest', 'Shoulders', 'Triceps'],
|
||||
volumeLoad: 7840, // kg
|
||||
exercises: [
|
||||
{
|
||||
name: 'Incline Dumbbell Press',
|
||||
muscleGroup: 'Upper Chest',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 32, reps: 10, rpe: 8 },
|
||||
{ setNumber: 2, weight: 32, reps: 9, rpe: 8.5 },
|
||||
{ setNumber: 3, weight: 34, reps: 8, rpe: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Barbell Flat Bench Press',
|
||||
muscleGroup: 'Mid Chest',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 80, reps: 8, rpe: 8 },
|
||||
{ setNumber: 2, weight: 85, reps: 6, rpe: 8.5 },
|
||||
{ setNumber: 3, weight: 85, reps: 6, rpe: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cable Lateral Raises (Lengthened Bias)',
|
||||
muscleGroup: 'Side Delts',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 12, reps: 15, rpe: 8.5 },
|
||||
{ setNumber: 2, weight: 12, reps: 14, rpe: 9 },
|
||||
{ setNumber: 3, weight: 14, reps: 11, rpe: 9.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: 'Prioritized lengthened position on incline press. Controlled 3-second eccentrics.',
|
||||
},
|
||||
{
|
||||
id: 'w-2',
|
||||
name: 'Pull & Posterior Chain (Lats & Hamstrings)',
|
||||
date: getFormattedDate(-2),
|
||||
muscleGroups: ['Back', 'Biceps', 'Hamstrings'],
|
||||
volumeLoad: 8960,
|
||||
exercises: [
|
||||
{
|
||||
name: 'Romanian Deadlift (RDL)',
|
||||
muscleGroup: 'Hamstrings',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 110, reps: 8, rpe: 8 },
|
||||
{ setNumber: 2, weight: 115, reps: 8, rpe: 8.5 },
|
||||
{ setNumber: 3, weight: 120, reps: 6, rpe: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Neutral Grip Lat Pulldown',
|
||||
muscleGroup: 'Lats',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 70, reps: 10, rpe: 8 },
|
||||
{ setNumber: 2, weight: 75, reps: 9, rpe: 8.5 },
|
||||
{ setNumber: 3, weight: 75, reps: 8, rpe: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Incline Dumbbell Curl',
|
||||
muscleGroup: 'Biceps',
|
||||
sets: [
|
||||
{ setNumber: 1, weight: 14, reps: 12, rpe: 8.5 },
|
||||
{ setNumber: 2, weight: 14, reps: 10, rpe: 9 },
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: 'Deep stretch at bottom of RDLs. Strong lat activation without excessive momentum.',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_MUSCLE_RECOVERY: MuscleRecovery[] = [
|
||||
{
|
||||
muscle: 'Chest',
|
||||
lastTrainedDaysAgo: 0,
|
||||
recoveryHours: 48,
|
||||
percentRecovered: 35,
|
||||
status: 'fatigued',
|
||||
},
|
||||
{
|
||||
muscle: 'Triceps & Delts',
|
||||
lastTrainedDaysAgo: 0,
|
||||
recoveryHours: 48,
|
||||
percentRecovered: 40,
|
||||
status: 'fatigued',
|
||||
},
|
||||
{
|
||||
muscle: 'Back & Lats',
|
||||
lastTrainedDaysAgo: 2,
|
||||
recoveryHours: 48,
|
||||
percentRecovered: 90,
|
||||
status: 'ready',
|
||||
},
|
||||
{
|
||||
muscle: 'Hamstrings & Glutes',
|
||||
lastTrainedDaysAgo: 2,
|
||||
recoveryHours: 72,
|
||||
percentRecovered: 85,
|
||||
status: 'recovering',
|
||||
},
|
||||
{
|
||||
muscle: 'Quadriceps',
|
||||
lastTrainedDaysAgo: 4,
|
||||
recoveryHours: 72,
|
||||
percentRecovered: 100,
|
||||
status: 'ready',
|
||||
},
|
||||
{
|
||||
muscle: 'Core / Abs',
|
||||
lastTrainedDaysAgo: 3,
|
||||
recoveryHours: 24,
|
||||
percentRecovered: 100,
|
||||
status: 'ready',
|
||||
},
|
||||
];
|
||||
|
||||
export const SCIENCE_TIPS = [
|
||||
{
|
||||
title: 'Mechanical Tension is King',
|
||||
concept: 'Hypertrophy Driver #1',
|
||||
description: 'Mechanical tension created by heavy loads or proximity to failure (RPE 7-9) is the primary driver of muscle hypertrophy—not metabolic "burn" or soreness.',
|
||||
},
|
||||
{
|
||||
title: 'Stimulus-to-Fatigue Ratio (SFR)',
|
||||
concept: 'Exercise Selection Science',
|
||||
description: 'Prioritize movements that provide maximal tension to target tissues with minimal systemic axial fatigue and joint irritation (e.g., Incline DB Press vs Flat Barbell).',
|
||||
},
|
||||
{
|
||||
title: 'Optimal Protein Dose per Meal',
|
||||
concept: 'Leucine Threshold (MPS)',
|
||||
description: 'Aim for ~0.4g of protein per kg of bodyweight per meal (approx 30-45g) containing ≥2.7g Leucine to trigger the Muscle Protein Synthesis (MPS) peak.',
|
||||
},
|
||||
{
|
||||
title: 'When to Deload',
|
||||
concept: 'Fatigue Management',
|
||||
description: 'If progressive overload stalls for 2 consecutive weeks across main lifts while joint aching rises, take a 1-week deload: cut volume sets by 50% while maintaining load intensity.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
export const MOCK_USERS = [
|
||||
{ id: "u1", username: "alex_travels", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026704d", hasStory: true },
|
||||
{ id: "u2", username: "sarah_styles", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026024d", hasStory: true },
|
||||
{ id: "u3", username: "arch_daily", avatar: "https://i.pravatar.cc/150?u=a042581f4e290267041", hasStory: false },
|
||||
{ id: "u4", username: "foodie.crush", avatar: "https://i.pravatar.cc/150?u=a04258a2462d826712d", hasStory: true },
|
||||
{ id: "u5", username: "neo_tokyo", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026703d", hasStory: true },
|
||||
{ id: "u6", username: "minimal_ui", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026705d", hasStory: false },
|
||||
{ id: "u7", username: "jordan_fitness", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026706d", hasStory: true },
|
||||
{ id: "u8", username: "coffee_vibes", avatar: "https://i.pravatar.cc/150?u=a042581f4e29026707d", hasStory: true },
|
||||
];
|
||||
|
||||
export const MOCK_POSTS = [
|
||||
{
|
||||
id: "p1",
|
||||
author: MOCK_USERS[0],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[4/5]",
|
||||
isVideo: false,
|
||||
likes: 1245,
|
||||
comments: 45,
|
||||
caption: "Lost in the majestic mountains of Switzerland 🏔️✨ #travel #nature",
|
||||
tags: ["travel", "nature"],
|
||||
createdAt: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
author: MOCK_USERS[1],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[3/4]",
|
||||
isVideo: false,
|
||||
likes: 8902,
|
||||
comments: 112,
|
||||
caption: "Autumn street style essentials 🍂🧥 #fashion #streetwear",
|
||||
tags: ["fashion", "streetwear"],
|
||||
createdAt: "5h ago",
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
author: MOCK_USERS[2],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-square",
|
||||
isVideo: false,
|
||||
likes: 432,
|
||||
comments: 12,
|
||||
caption: "Modern minimalist interior goals. Notice the lighting integration.",
|
||||
tags: ["architecture", "interior"],
|
||||
createdAt: "1d ago",
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
author: MOCK_USERS[3],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[9/16]", // TikTok / Reels size
|
||||
isVideo: false, // In a real app we would play a video here
|
||||
likes: 21000,
|
||||
comments: 489,
|
||||
caption: "Making the perfect Neapolitan pizza from scratch 🍕👨🍳 #pizza #cooking",
|
||||
tags: ["food"],
|
||||
createdAt: "1d ago",
|
||||
},
|
||||
{
|
||||
id: "p5",
|
||||
author: MOCK_USERS[4],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1514933651103-005eec06c04b?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[4/5]",
|
||||
isVideo: false,
|
||||
likes: 980,
|
||||
comments: 24,
|
||||
caption: "Cyberpunk nights in Shinjuku 🗼☔",
|
||||
tags: ["travel", "nightlife", "photography"],
|
||||
createdAt: "2d ago",
|
||||
},
|
||||
{
|
||||
id: "p6",
|
||||
author: MOCK_USERS[5],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-video",
|
||||
isVideo: false,
|
||||
likes: 124,
|
||||
comments: 3,
|
||||
caption: "Clean desk setup. Less is more.",
|
||||
tags: ["tech", "minimal"],
|
||||
createdAt: "2d ago",
|
||||
},
|
||||
{
|
||||
id: "p7",
|
||||
author: MOCK_USERS[6],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[9/16]",
|
||||
isVideo: false,
|
||||
likes: 15400,
|
||||
comments: 312,
|
||||
caption: "Morning run by the river. Grinding never stops! 💪🏃♂️",
|
||||
tags: ["fitness", "lifestyle"],
|
||||
createdAt: "3d ago",
|
||||
},
|
||||
{
|
||||
id: "p8",
|
||||
author: MOCK_USERS[7],
|
||||
mediaUrl: "https://images.unsplash.com/photo-1497935586351-b67a49e012bf?q=80&w=1500&auto=format&fit=crop",
|
||||
aspectRatio: "aspect-[1/1]",
|
||||
isVideo: false,
|
||||
likes: 673,
|
||||
comments: 41,
|
||||
caption: "Perfect pour over to start the day ☕✨",
|
||||
tags: ["coffee", "morning"],
|
||||
createdAt: "4d ago",
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'completed';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
priority: Priority;
|
||||
category: string;
|
||||
dueDate?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
frequency: 'daily' | 'weekly';
|
||||
streak: number;
|
||||
bestStreak: number;
|
||||
completedDates: string[]; // ISO date string YYYY-MM-DD
|
||||
color: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ExpenseItem {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
type: 'income' | 'expense';
|
||||
category: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
category: 'work' | 'personal' | 'study' | 'health';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
mood?: 1 | 2 | 3 | 4 | 5; // 1: Stressed, 2: Tired, 3: Neutral, 4: Good, 5: Fantastic
|
||||
updatedAt: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface ExerciseSet {
|
||||
setNumber: number;
|
||||
weight: number; // in kg
|
||||
reps: number;
|
||||
rpe: number; // Rate of Perceived Exertion (6 - 10)
|
||||
}
|
||||
|
||||
export interface WorkoutExercise {
|
||||
name: string;
|
||||
muscleGroup: string;
|
||||
sets: ExerciseSet[];
|
||||
}
|
||||
|
||||
export interface WorkoutSession {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
muscleGroups: string[];
|
||||
volumeLoad: number; // sum of (weight * reps) across all sets in kg
|
||||
exercises: WorkoutExercise[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DailyNutrition {
|
||||
date: string;
|
||||
calories: number;
|
||||
calorieTarget: number;
|
||||
protein: number; // in grams
|
||||
proteinTarget: number; // in grams
|
||||
carbs: number; // in grams
|
||||
carbsTarget: number;
|
||||
fats: number; // in grams
|
||||
fatsTarget: number;
|
||||
waterLiters: number;
|
||||
creatineTaken: boolean;
|
||||
bodyweightKg: number;
|
||||
}
|
||||
|
||||
export interface MuscleRecovery {
|
||||
muscle: string;
|
||||
lastTrainedDaysAgo: number;
|
||||
recoveryHours: number; // Typically 48-72h for MPS
|
||||
percentRecovered: number; // 0 - 100
|
||||
status: 'ready' | 'recovering' | 'fatigued';
|
||||
}
|
||||
|
||||
export type ActiveModule =
|
||||
| 'dashboard'
|
||||
| 'fitness'
|
||||
| 'tasks'
|
||||
| 'habits'
|
||||
| 'calendar'
|
||||
| 'finance'
|
||||
| 'notes'
|
||||
| 'pomodoro';
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
|
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (item) {
|
||||
setStoredValue(JSON.parse(item));
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(initialValue));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, [key]);
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user