Initial commit

This commit is contained in:
David Athay 2025-06-08 11:18:08 +01:00
commit 76d3c574c1
31 changed files with 6182 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View File

@ -0,0 +1,5 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
index.html Normal file
View File

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

4051
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

39
public/magpie-logo.svg Normal file
View File

@ -0,0 +1,39 @@
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.magpie-body { fill: #1a1a1a; }
.magpie-belly { fill: #f5f5f5; }
.magpie-book { fill: #2a2a2a; stroke: #1a1a1a; stroke-width: 3; }
.magpie-eye { fill: #f5f5f5; }
</style>
</defs>
<!-- Book -->
<path d="M150 280 L250 280 L270 320 L130 320 Z" class="magpie-book"/>
<path d="M150 280 L250 280 L245 290 L155 290 Z" class="magpie-book"/>
<!-- Magpie Body -->
<ellipse cx="200" cy="220" rx="45" ry="65" class="magpie-body"/>
<!-- Magpie Belly -->
<ellipse cx="200" cy="235" rx="25" ry="35" class="magpie-belly"/>
<!-- Magpie Head -->
<circle cx="200" cy="160" r="35" class="magpie-body"/>
<!-- Magpie Beak -->
<path d="M225 155 L245 160 L225 165 Z" class="magpie-body"/>
<!-- Magpie Eye -->
<circle cx="210" cy="150" r="6" class="magpie-eye"/>
<!-- Magpie Tail -->
<path d="M155 250 L120 280 L140 290 L175 260 Z" class="magpie-body"/>
<!-- Magpie Wing -->
<ellipse cx="185" cy="210" rx="15" ry="30" class="magpie-body" transform="rotate(-20 185 210)"/>
<!-- Magpie Feet -->
<path d="M185 285 L185 295 M190 295 L180 295 M195 295 L185 295" class="magpie-body" stroke="#1a1a1a" stroke-width="2" fill="none"/>
<path d="M215 285 L215 295 M220 295 L210 295 M225 295 L215 295" class="magpie-body" stroke="#1a1a1a" stroke-width="2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

47
src/App.tsx Normal file
View File

@ -0,0 +1,47 @@
import React, { useState } from 'react';
import Navigation from './components/Navigation';
import PostFeed from './components/PostFeed';
import CreatePost from './components/CreatePost';
import { ThemeProvider } from './contexts/ThemeContext';
import { Post } from './types';
function App() {
const [posts, setPosts] = useState<Post[]>([]);
const [showCreatePost, setShowCreatePost] = useState(false);
const addPost = (newPost: Post) => {
setPosts(prev => [newPost, ...prev]);
setShowCreatePost(false);
};
const toggleLike = (postId: string) => {
setPosts(prev => prev.map(post =>
post.id === postId
? { ...post, likes: post.isLiked ? post.likes - 1 : post.likes + 1, isLiked: !post.isLiked }
: post
));
};
return (
<ThemeProvider>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<Navigation onCreatePost={() => setShowCreatePost(true)} />
<main className="max-w-2xl mx-auto px-4 py-8">
{showCreatePost && (
<div className="mb-8">
<CreatePost
onPost={addPost}
onCancel={() => setShowCreatePost(false)}
/>
</div>
)}
<PostFeed posts={posts} onToggleLike={toggleLike} />
</main>
</div>
</ThemeProvider>
);
}
export default App;

View File

@ -0,0 +1,341 @@
import React, { useState, useRef } from 'react';
import { X, Image, Video, Users, Lock, Globe, Calendar, Clock, Plus, Trash2 } from 'lucide-react';
import { Post, MediaFile, SharingSettings, ContentBlock } from '../types';
import FileUpload from './FileUpload';
import ShareSettings from './ShareSettings';
import ScheduleSettings from './ScheduleSettings';
interface CreatePostProps {
onPost: (post: Post) => void;
onCancel: () => void;
}
const CreatePost: React.FC<CreatePostProps> = ({ onPost, onCancel }) => {
const [title, setTitle] = useState('');
const [contentBlocks, setContentBlocks] = useState<ContentBlock[]>([
{ id: '1', type: 'text', content: '' }
]);
const [sharing, setSharing] = useState<SharingSettings>({
type: 'private',
description: 'Only you can see this post'
});
const [scheduledFor, setScheduledFor] = useState<Date | null>(null);
const [showShareSettings, setShowShareSettings] = useState(false);
const [showScheduleSettings, setShowScheduleSettings] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addContentBlock = (type: 'text' | 'image' | 'video', afterIndex?: number) => {
const newBlock: ContentBlock = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
type,
content: ''
};
const insertIndex = afterIndex !== undefined ? afterIndex + 1 : contentBlocks.length;
const newBlocks = [...contentBlocks];
newBlocks.splice(insertIndex, 0, newBlock);
setContentBlocks(newBlocks);
};
const updateContentBlock = (id: string, content: string, mediaFile?: MediaFile) => {
setContentBlocks(prev => prev.map(block =>
block.id === id
? { ...block, content, mediaFile }
: block
));
};
const removeContentBlock = (id: string) => {
if (contentBlocks.length > 1) {
setContentBlocks(prev => prev.filter(block => block.id !== id));
}
};
const handleImageUpload = (blockId: string) => {
fileInputRef.current?.click();
fileInputRef.current!.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const mediaFile: MediaFile = {
id: Date.now().toString(),
type: file.type.startsWith('video/') ? 'video' : 'image',
url: URL.createObjectURL(file),
filename: file.name
};
updateContentBlock(blockId, '', mediaFile);
}
};
};
const handleSubmit = () => {
const hasContent = title.trim() || contentBlocks.some(block =>
block.content.trim() || block.mediaFile
);
if (!hasContent) return;
const now = new Date();
const postTitle = title.trim() || `Post by You at ${now.toLocaleDateString()}`;
// Extract media files from content blocks
const media = contentBlocks
.filter(block => block.mediaFile)
.map(block => block.mediaFile!);
const newPost: Post = {
id: Date.now().toString(),
title: postTitle,
content: JSON.stringify(contentBlocks), // Store structured content
media: media.length > 0 ? media : undefined,
author: {
name: 'You',
avatar: 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?auto=compress&cs=tinysrgb&w=32&h=32&dpr=2'
},
timestamp: scheduledFor || now,
scheduledFor,
isScheduled: !!scheduledFor,
likes: 0,
isLiked: false,
sharing,
comments: []
};
onPost(newPost);
};
const getSharingIcon = () => {
switch (sharing.type) {
case 'public': return Globe;
case 'groups': return Users;
default: return Lock;
}
};
const getSharingText = () => {
switch (sharing.type) {
case 'public': return 'Public';
case 'groups': return 'Groups';
default: return 'Private';
}
};
const SharingIcon = getSharingIcon();
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<input
type="file"
ref={fileInputRef}
accept="image/*,video/*"
className="hidden"
/>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create Post</h3>
<button
onClick={onCancel}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-4">
<div className="flex space-x-3 mb-4">
<div className="w-10 h-10 rounded-full bg-gray-900 dark:bg-white flex items-center justify-center text-white dark:text-gray-900 font-medium">
Y
</div>
<div className="flex-1">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title (optional)"
className="w-full border-none outline-none text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 text-lg font-semibold mb-2 bg-transparent"
/>
</div>
</div>
{/* Content Blocks */}
<div className="space-y-4">
{contentBlocks.map((block, index) => (
<div key={block.id} className="group relative">
{block.type === 'text' ? (
<div className="relative">
<textarea
value={block.content}
onChange={(e) => updateContentBlock(block.id, e.target.value)}
placeholder={index === 0 ? "What's on your mind?" : "Continue writing..."}
className="w-full resize-none border-none outline-none text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 text-base leading-relaxed min-h-[60px] focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:ring-opacity-20 rounded-lg p-2 bg-transparent"
rows={3}
/>
{/* Add content buttons */}
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
<button
onClick={() => addContentBlock('text', index)}
className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Add text block"
>
<Plus size={16} />
</button>
<button
onClick={() => addContentBlock('image', index)}
className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Add image"
>
<Image size={16} />
</button>
<button
onClick={() => addContentBlock('video', index)}
className="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Add video"
>
<Video size={16} />
</button>
{contentBlocks.length > 1 && (
<button
onClick={() => removeContentBlock(block.id)}
className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Remove block"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
) : (
<div className="relative">
{block.mediaFile ? (
<div className="relative group">
{block.mediaFile.type === 'image' ? (
<img
src={block.mediaFile.url}
alt={block.mediaFile.filename}
className="w-full max-h-96 object-cover rounded-lg"
/>
) : (
<video
src={block.mediaFile.url}
className="w-full max-h-96 object-cover rounded-lg"
controls
/>
)}
<button
onClick={() => removeContentBlock(block.id)}
className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</div>
) : (
<div
onClick={() => handleImageUpload(block.id)}
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center cursor-pointer hover:border-gray-400 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
{block.type === 'image' ? (
<>
<Image size={32} className="mx-auto text-gray-400 dark:text-gray-500 mb-2" />
<p className="text-gray-500 dark:text-gray-400">Click to add an image</p>
</>
) : (
<>
<Video size={32} className="mx-auto text-gray-400 dark:text-gray-500 mb-2" />
<p className="text-gray-500 dark:text-gray-400">Click to add a video</p>
</>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
{/* Share Settings Modal */}
{showShareSettings && (
<div className="mb-4">
<ShareSettings
sharing={sharing}
onSharingChange={setSharing}
onClose={() => setShowShareSettings(false)}
/>
</div>
)}
{/* Schedule Settings Modal */}
{showScheduleSettings && (
<div className="mb-4">
<ScheduleSettings
scheduledFor={scheduledFor}
onScheduleChange={setScheduledFor}
onClose={() => setShowScheduleSettings(false)}
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
<div className="flex items-center space-x-2">
<button
onClick={() => addContentBlock('image')}
className="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Image size={18} />
<span className="text-sm font-medium">Photo</span>
</button>
<button
onClick={() => addContentBlock('video')}
className="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Video size={18} />
<span className="text-sm font-medium">Video</span>
</button>
<button
onClick={() => setShowScheduleSettings(!showScheduleSettings)}
className={`flex items-center space-x-2 transition-colors p-2 rounded-lg ${
scheduledFor
? 'text-gray-900 dark:text-white bg-gray-200 dark:bg-gray-700'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{scheduledFor ? <Clock size={18} /> : <Calendar size={18} />}
<span className="text-sm font-medium">
{scheduledFor ? 'Scheduled' : 'Schedule'}
</span>
</button>
<button
onClick={() => setShowShareSettings(!showShareSettings)}
className="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<SharingIcon size={18} />
<span className="text-sm font-medium">{getSharingText()}</span>
</button>
</div>
<div className="flex items-center space-x-3">
{scheduledFor && (
<div className="text-sm text-gray-600 dark:text-gray-400 font-medium">
{scheduledFor.toLocaleDateString()} at {scheduledFor.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
)}
<button
onClick={handleSubmit}
disabled={!title.trim() && !contentBlocks.some(block => block.content.trim() || block.mediaFile)}
className="bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-6 py-2 rounded-full font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
>
{scheduledFor ? 'Schedule Post' : 'Post'}
</button>
</div>
</div>
</div>
);
};
export default CreatePost;

View File

@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { X, User, Mail, MapPin, Link, Calendar, Camera } from 'lucide-react';
interface EditProfileModalProps {
isOpen: boolean;
onClose: () => void;
}
const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose }) => {
const [formData, setFormData] = useState({
name: 'You',
email: 'you@example.com',
bio: '',
location: '',
website: '',
birthdate: ''
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSave = () => {
console.log('Saving profile:', formData);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Edit Profile</h2>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Profile Picture */}
<div className="flex items-center space-x-6 mb-8">
<div className="relative">
<div className="w-24 h-24 rounded-full bg-gray-900 dark:bg-white flex items-center justify-center text-white dark:text-gray-900 font-bold text-2xl">
Y
</div>
<button className="absolute bottom-0 right-0 w-8 h-8 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-full flex items-center justify-center hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors">
<Camera size={16} />
</button>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">Profile Picture</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">Upload a new profile picture</p>
<button className="text-sm text-gray-900 dark:text-white font-medium hover:underline">
Change Photo
</button>
</div>
</div>
{/* Form Fields */}
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<User size={16} className="inline mr-2" />
Display Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your display name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Mail size={16} className="inline mr-2" />
Email Address
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="your@email.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Bio
</label>
<textarea
name="bio"
value={formData.bio}
onChange={handleInputChange}
rows={4}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
placeholder="Tell us about yourself..."
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{formData.bio.length}/160 characters
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<MapPin size={16} className="inline mr-2" />
Location
</label>
<input
type="text"
name="location"
value={formData.location}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="City, Country"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Link size={16} className="inline mr-2" />
Website
</label>
<input
type="url"
name="website"
value={formData.website}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="https://yourwebsite.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Calendar size={16} className="inline mr-2" />
Birth Date
</label>
<input
type="date"
name="birthdate"
value={formData.birthdate}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end space-x-4 p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
<button
onClick={onClose}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-6 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors font-medium"
>
Save Changes
</button>
</div>
</div>
</div>
);
};
export default EditProfileModal;

View File

@ -0,0 +1,58 @@
import React, { useRef } from 'react';
import { MediaFile } from '../types';
interface FileUploadProps {
children: React.ReactNode;
onFilesSelected: (files: MediaFile[]) => void;
accept?: string;
multiple?: boolean;
}
const FileUpload: React.FC<FileUploadProps> = ({
children,
onFilesSelected,
accept = "image/*",
multiple = true
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const mediaFiles: MediaFile[] = files.map(file => ({
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
type: file.type.startsWith('video/') ? 'video' : 'image',
url: URL.createObjectURL(file),
filename: file.name
}));
onFilesSelected(mediaFiles);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept={accept}
multiple={multiple}
className="hidden"
/>
<div onClick={handleClick} className="cursor-pointer">
{children}
</div>
</>
);
};
export default FileUpload;

View File

@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { Home, Plus, Search, Bell, Moon, Sun } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import UserDropdown from './UserDropdown';
import EditProfileModal from './EditProfileModal';
import PreferencesModal from './PreferencesModal';
interface NavigationProps {
onCreatePost: () => void;
}
const Navigation: React.FC<NavigationProps> = ({ onCreatePost }) => {
const { isDarkMode, toggleDarkMode } = useTheme();
const [showEditProfile, setShowEditProfile] = useState(false);
const [showPreferences, setShowPreferences] = useState(false);
const handleThemeToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
console.log('Theme toggle clicked, current mode:', isDarkMode);
toggleDarkMode();
};
return (
<>
<nav className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 flex items-center justify-center">
<svg viewBox="0 0 400 400" className="w-8 h-8">
<defs>
<style>
{`.magpie-body { fill: ${isDarkMode ? '#f5f5f5' : '#1a1a1a'}; }
.magpie-belly { fill: ${isDarkMode ? '#1a1a1a' : '#f5f5f5'}; }
.magpie-book { fill: ${isDarkMode ? '#d1d5db' : '#2a2a2a'}; stroke: ${isDarkMode ? '#f5f5f5' : '#1a1a1a'}; stroke-width: 3; }
.magpie-eye { fill: ${isDarkMode ? '#1a1a1a' : '#f5f5f5'}; }`}
</style>
</defs>
{/* Book */}
<path d="M150 280 L250 280 L270 320 L130 320 Z" className="magpie-book"/>
<path d="M150 280 L250 280 L245 290 L155 290 Z" className="magpie-book"/>
{/* Magpie Body */}
<ellipse cx="200" cy="220" rx="45" ry="65" className="magpie-body"/>
{/* Magpie Belly */}
<ellipse cx="200" cy="235" rx="25" ry="35" className="magpie-belly"/>
{/* Magpie Head */}
<circle cx="200" cy="160" r="35" className="magpie-body"/>
{/* Magpie Beak */}
<path d="M225 155 L245 160 L225 165 Z" className="magpie-body"/>
{/* Magpie Eye */}
<circle cx="210" cy="150" r="6" className="magpie-eye"/>
{/* Magpie Tail */}
<path d="M155 250 L120 280 L140 290 L175 260 Z" className="magpie-body"/>
{/* Magpie Wing */}
<ellipse cx="185" cy="210" rx="15" ry="30" className="magpie-body" transform="rotate(-20 185 210)"/>
{/* Magpie Feet */}
<path d="M185 285 L185 295 M190 295 L180 295 M195 295 L185 295" className="magpie-body" stroke={isDarkMode ? '#f5f5f5' : '#1a1a1a'} strokeWidth="2" fill="none"/>
<path d="M215 285 L215 295 M220 295 L210 295 M225 295 L215 295" className="magpie-body" stroke={isDarkMode ? '#f5f5f5' : '#1a1a1a'} strokeWidth="2" fill="none"/>
</svg>
</div>
<span className="text-2xl font-bold text-gray-900 dark:text-white">
Magpie
</span>
</div>
{/* Center Navigation */}
<div className="hidden md:flex items-center space-x-8">
<button className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
<Home size={20} />
<span className="font-medium">Home</span>
</button>
<button className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
<Search size={20} />
<span className="font-medium">Explore</span>
</button>
</div>
{/* Right Navigation */}
<div className="flex items-center space-x-4">
<button
onClick={onCreatePost}
className="bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-4 py-2 rounded-full flex items-center space-x-2 hover:bg-gray-800 dark:hover:bg-gray-100 transform hover:scale-105 transition-all duration-200"
>
<Plus size={18} />
<span className="hidden sm:inline font-medium">Create</span>
</button>
<button
onClick={handleThemeToggle}
className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
</button>
<button className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<Bell size={20} />
</button>
<UserDropdown
onEditProfile={() => setShowEditProfile(true)}
onPreferences={() => setShowPreferences(true)}
/>
</div>
</div>
</div>
</nav>
{/* Modals */}
<EditProfileModal
isOpen={showEditProfile}
onClose={() => setShowEditProfile(false)}
/>
<PreferencesModal
isOpen={showPreferences}
onClose={() => setShowPreferences(false)}
/>
</>
);
};
export default Navigation;

233
src/components/PostCard.tsx Normal file
View File

@ -0,0 +1,233 @@
import React, { useState } from 'react';
import { Heart, MessageCircle, Share, MoreHorizontal, Lock, Users, Globe, Clock, Calendar } from 'lucide-react';
import { Post, ContentBlock } from '../types';
interface PostCardProps {
post: Post;
onToggleLike: (postId: string) => void;
}
const PostCard: React.FC<PostCardProps> = ({ post, onToggleLike }) => {
const [showComments, setShowComments] = useState(false);
const formatTime = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
return `${days}d`;
};
const getSharingIcon = () => {
switch (post.sharing.type) {
case 'public': return Globe;
case 'groups': return Users;
default: return Lock;
}
};
const getSharingText = () => {
switch (post.sharing.type) {
case 'public': return 'Public';
case 'groups': return 'Groups';
default: 'Private';
}
};
const getSharingDescription = () => {
return post.sharing.description || getSharingText();
};
const renderContent = () => {
try {
const contentBlocks: ContentBlock[] = JSON.parse(post.content);
return (
<div className="space-y-4">
{contentBlocks.map((block) => {
if (block.type === 'text' && block.content.trim()) {
return (
<div key={block.id} className="text-gray-900 dark:text-white leading-relaxed whitespace-pre-wrap">
{block.content}
</div>
);
} else if (block.type === 'image' && block.mediaFile) {
return (
<div key={block.id} className="rounded-xl overflow-hidden">
<img
src={block.mediaFile.url}
alt={block.mediaFile.filename}
className="w-full object-cover max-h-96"
/>
</div>
);
} else if (block.type === 'video' && block.mediaFile) {
return (
<div key={block.id} className="rounded-xl overflow-hidden">
<video
src={block.mediaFile.url}
controls
className="w-full object-cover max-h-96"
/>
</div>
);
}
return null;
})}
</div>
);
} catch {
// Fallback for old format posts
return (
<div className="text-gray-900 dark:text-white leading-relaxed">
{post.content}
</div>
);
}
};
const SharingIcon = getSharingIcon();
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-xl transition-shadow duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full object-cover"
/>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">{post.author.name}</h4>
<div className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<span>{formatTime(post.timestamp)}</span>
<span></span>
<div className="flex items-center space-x-1">
<SharingIcon size={14} />
<span>{getSharingDescription()}</span>
</div>
{post.isScheduled && (
<>
<span></span>
<div className="flex items-center space-x-1 text-gray-600 dark:text-gray-400">
<Clock size={14} />
<span>Scheduled</span>
</div>
</>
)}
</div>
</div>
</div>
<button className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700">
<MoreHorizontal size={20} />
</button>
</div>
{/* Title */}
<div className="px-4 pb-2">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{post.title}</h3>
</div>
{/* Content */}
<div className="px-4 pb-4">
{renderContent()}
</div>
{/* Scheduled Post Notice */}
{post.isScheduled && post.scheduledFor && (
<div className="mx-4 mb-4 p-3 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg">
<div className="flex items-center space-x-2 text-gray-700 dark:text-gray-300">
<Calendar size={16} />
<span className="text-sm font-medium">
Scheduled for {post.scheduledFor.toLocaleDateString()} at{' '}
{post.scheduledFor.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
)}
{/* Actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-6">
<button
onClick={() => onToggleLike(post.id)}
className={`flex items-center space-x-2 transition-all duration-200 ${
post.isLiked
? 'text-red-500 hover:text-red-600'
: 'text-gray-600 dark:text-gray-400 hover:text-red-500'
}`}
>
<Heart
size={20}
className={post.isLiked ? 'fill-current' : ''}
/>
<span className="text-sm font-medium">{post.likes}</span>
</button>
<button
onClick={() => setShowComments(!showComments)}
className="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<MessageCircle size={20} />
<span className="text-sm font-medium">{post.comments.length}</span>
</button>
<button className="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<Share size={20} />
<span className="text-sm font-medium">Share</span>
</button>
</div>
</div>
</div>
{/* Comments Section */}
{showComments && (
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
<div className="space-y-3">
{post.comments.map((comment) => (
<div key={comment.id} className="flex space-x-3">
<img
src={comment.author.avatar}
alt={comment.author.name}
className="w-8 h-8 rounded-full object-cover"
/>
<div className="flex-1">
<div className="bg-white dark:bg-gray-700 rounded-xl px-3 py-2">
<h5 className="font-medium text-sm text-gray-900 dark:text-white">
{comment.author.name}
</h5>
<p className="text-sm text-gray-700 dark:text-gray-300">{comment.content}</p>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-3">
{formatTime(comment.timestamp)}
</div>
</div>
</div>
))}
<div className="flex space-x-3 mt-4">
<div className="w-8 h-8 rounded-full bg-gray-900 dark:bg-white flex items-center justify-center text-white dark:text-gray-900 font-medium text-sm">
Y
</div>
<div className="flex-1">
<input
type="text"
placeholder="Write a comment..."
className="w-full bg-white dark:bg-gray-700 rounded-full px-4 py-2 border border-gray-200 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-transparent text-sm text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
/>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default PostCard;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Post } from '../types';
import PostCard from './PostCard';
interface PostFeedProps {
posts: Post[];
onToggleLike: (postId: string) => void;
}
const PostFeed: React.FC<PostFeedProps> = ({ posts, onToggleLike }) => {
if (posts.length === 0) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white dark:text-gray-900 text-2xl">📝</span>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">No posts yet</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">Start sharing your thoughts and media with others!</p>
<div className="text-sm text-gray-400 dark:text-gray-500">
Click the "Create" button above to get started
</div>
</div>
);
}
return (
<div className="space-y-6">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onToggleLike={onToggleLike}
/>
))}
</div>
);
};
export default PostFeed;

View File

@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { X, Bell, Eye, Shield, Palette, Globe, Moon, Sun, Monitor } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
interface PreferencesModalProps {
isOpen: boolean;
onClose: () => void;
}
const PreferencesModal: React.FC<PreferencesModalProps> = ({ isOpen, onClose }) => {
const { isDarkMode, toggleDarkMode } = useTheme();
const [preferences, setPreferences] = useState({
notifications: {
email: true,
push: true,
likes: true,
comments: true,
follows: true,
mentions: true
},
privacy: {
profileVisibility: 'public',
showEmail: false,
showLocation: true,
allowTagging: true
},
display: {
theme: isDarkMode ? 'dark' : 'light',
language: 'en',
timezone: 'auto'
}
});
const handleNotificationChange = (key: string, value: boolean) => {
setPreferences(prev => ({
...prev,
notifications: { ...prev.notifications, [key]: value }
}));
};
const handlePrivacyChange = (key: string, value: string | boolean) => {
setPreferences(prev => ({
...prev,
privacy: { ...prev.privacy, [key]: value }
}));
};
const handleDisplayChange = (key: string, value: string) => {
setPreferences(prev => ({
...prev,
display: { ...prev.display, [key]: value }
}));
if (key === 'theme') {
if ((value === 'dark' && !isDarkMode) || (value === 'light' && isDarkMode)) {
toggleDarkMode();
}
}
};
const handleSave = () => {
console.log('Saving preferences:', preferences);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Preferences</h2>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Notifications */}
<div className="space-y-6">
<div className="flex items-center space-x-3 mb-4">
<Bell size={24} className="text-gray-600 dark:text-gray-400" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Notifications</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Email Notifications</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Receive notifications via email</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.notifications.email}
onChange={(e) => handleNotificationChange('email', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-300 dark:peer-focus:ring-gray-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-gray-600"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Push Notifications</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Receive push notifications</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.notifications.push}
onChange={(e) => handleNotificationChange('push', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-300 dark:peer-focus:ring-gray-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-gray-600"></div>
</label>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Notify me when:</h4>
<div className="space-y-3">
{[
{ key: 'likes', label: 'Someone likes my post' },
{ key: 'comments', label: 'Someone comments on my post' },
{ key: 'follows', label: 'Someone follows me' },
{ key: 'mentions', label: 'Someone mentions me' }
].map(({ key, label }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.notifications[key as keyof typeof preferences.notifications]}
onChange={(e) => handleNotificationChange(key, e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-300 dark:peer-focus:ring-gray-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-gray-600"></div>
</label>
</div>
))}
</div>
</div>
</div>
</div>
{/* Privacy & Display */}
<div className="space-y-6">
{/* Privacy */}
<div>
<div className="flex items-center space-x-3 mb-4">
<Shield size={24} className="text-gray-600 dark:text-gray-400" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Privacy</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Profile Visibility
</label>
<select
value={preferences.privacy.profileVisibility}
onChange={(e) => handlePrivacyChange('profileVisibility', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="public">Public</option>
<option value="friends">Friends Only</option>
<option value="private">Private</option>
</select>
</div>
<div className="space-y-3">
{[
{ key: 'showEmail', label: 'Show email on profile' },
{ key: 'showLocation', label: 'Show location on profile' },
{ key: 'allowTagging', label: 'Allow others to tag me' }
].map(({ key, label }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={preferences.privacy[key as keyof typeof preferences.privacy] as boolean}
onChange={(e) => handlePrivacyChange(key, e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-300 dark:peer-focus:ring-gray-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-gray-600"></div>
</label>
</div>
))}
</div>
</div>
</div>
{/* Display */}
<div>
<div className="flex items-center space-x-3 mb-4">
<Palette size={24} className="text-gray-600 dark:text-gray-400" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Display</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Theme
</label>
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor }
].map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => handleDisplayChange('theme', value)}
className={`flex flex-col items-center space-y-2 p-3 rounded-lg border-2 transition-all ${
preferences.display.theme === value
? 'border-gray-900 dark:border-white bg-gray-100 dark:bg-gray-700'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<Icon size={20} className="text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">{label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Language
</label>
<select
value={preferences.display.language}
onChange={(e) => handleDisplayChange('language', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Timezone
</label>
<select
value={preferences.display.timezone}
onChange={(e) => handleDisplayChange('timezone', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="auto">Auto-detect</option>
<option value="UTC">UTC</option>
<option value="EST">Eastern Time</option>
<option value="PST">Pacific Time</option>
<option value="GMT">Greenwich Mean Time</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end space-x-4 p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
<button
onClick={onClose}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-6 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors font-medium"
>
Save Preferences
</button>
</div>
</div>
</div>
);
};
export default PreferencesModal;

View File

@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { Calendar, Clock, X } from 'lucide-react';
interface ScheduleSettingsProps {
scheduledFor: Date | null;
onScheduleChange: (date: Date | null) => void;
onClose: () => void;
}
const ScheduleSettings: React.FC<ScheduleSettingsProps> = ({
scheduledFor,
onScheduleChange,
onClose
}) => {
const [selectedDate, setSelectedDate] = useState(
scheduledFor ? scheduledFor.toISOString().split('T')[0] : ''
);
const [selectedTime, setSelectedTime] = useState(
scheduledFor ? scheduledFor.toTimeString().slice(0, 5) : ''
);
const handleSave = () => {
if (selectedDate && selectedTime) {
const dateTime = new Date(`${selectedDate}T${selectedTime}`);
onScheduleChange(dateTime);
} else {
onScheduleChange(null);
}
onClose();
};
const handleClear = () => {
setSelectedDate('');
setSelectedTime('');
onScheduleChange(null);
onClose();
};
const today = new Date().toISOString().split('T')[0];
const now = new Date().toTimeString().slice(0, 5);
return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center space-x-2">
<Calendar size={18} className="text-gray-600 dark:text-gray-400" />
<span>Schedule Post</span>
</h4>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X size={18} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={today}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Time
</label>
<input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
min={selectedDate === today ? now : undefined}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 dark:focus:ring-gray-400 focus:border-gray-500 dark:focus:border-gray-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{selectedDate && selectedTime && (
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<div className="flex items-center space-x-2 text-gray-700 dark:text-gray-300">
<Clock size={16} />
<span className="text-sm font-medium">
Scheduled for {new Date(`${selectedDate}T${selectedTime}`).toLocaleDateString()}
at {new Date(`${selectedDate}T${selectedTime}`).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
)}
<div className="flex space-x-2 pt-2">
<button
onClick={handleSave}
className="flex-1 bg-gray-900 dark:bg-white text-white dark:text-gray-900 py-2 px-4 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors font-medium"
>
{selectedDate && selectedTime ? 'Set Schedule' : 'Post Now'}
</button>
{scheduledFor && (
<button
onClick={handleClear}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Clear
</button>
)}
</div>
</div>
</div>
);
};
export default ScheduleSettings;

View File

@ -0,0 +1,139 @@
import React from 'react';
import { Lock, Users, Globe, X, Eye, UserCheck, Shield } from 'lucide-react';
import { SharingSettings } from '../types';
interface ShareSettingsProps {
sharing: SharingSettings;
onSharingChange: (sharing: SharingSettings) => void;
onClose: () => void;
}
const ShareSettings: React.FC<ShareSettingsProps> = ({ sharing, onSharingChange, onClose }) => {
const sharingOptions = [
{
type: 'private' as const,
icon: Lock,
title: 'Private',
description: 'Only you can see this post',
detailedDescription: 'This post will be visible only to you. It won\'t appear in any feeds or search results.',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-700',
borderColor: 'border-gray-200 dark:border-gray-600'
},
{
type: 'groups' as const,
icon: Users,
title: 'Groups',
description: 'Share with specific groups',
detailedDescription: 'This post will be visible to members of the groups you select.',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-700',
borderColor: 'border-gray-200 dark:border-gray-600'
},
{
type: 'public' as const,
icon: Globe,
title: 'Public',
description: 'Anyone can see this post',
detailedDescription: 'This post will be visible to everyone and may appear in public feeds and search results.',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-700',
borderColor: 'border-gray-200 dark:border-gray-600'
}
];
const handleOptionSelect = (option: typeof sharingOptions[0]) => {
onSharingChange({
type: option.type,
description: option.description
});
};
return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center space-x-2">
<Shield size={18} className="text-gray-600 dark:text-gray-400" />
<span>Post Privacy</span>
</h4>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X size={18} />
</button>
</div>
<div className="space-y-3">
{sharingOptions.map((option) => {
const Icon = option.icon;
const isSelected = sharing.type === option.type;
return (
<button
key={option.type}
onClick={() => handleOptionSelect(option)}
className={`w-full flex items-start space-x-3 p-4 rounded-lg transition-all border-2 ${
isSelected
? `${option.bgColor} ${option.borderColor}`
: 'hover:bg-gray-50 dark:hover:bg-gray-700 border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<Icon size={20} className={option.color} />
<div className="flex-1 text-left">
<div className="font-medium text-gray-900 dark:text-white flex items-center space-x-2">
<span>{option.title}</span>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-gray-600 dark:bg-gray-400"></div>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{option.description}</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-2">{option.detailedDescription}</div>
</div>
<div className="flex items-center space-x-1 text-xs text-gray-400 dark:text-gray-500">
<Eye size={14} />
<span>
{option.type === 'private' && 'Only you'}
{option.type === 'groups' && 'Group members'}
{option.type === 'public' && 'Everyone'}
</span>
</div>
</button>
);
})}
</div>
{sharing.type === 'groups' && (
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
<UserCheck size={16} className="text-gray-600 dark:text-gray-400" />
<span>Select Groups</span>
</label>
<div className="space-y-2">
{['Friends', 'Family', 'Work Colleagues', 'Photography Club'].map((group) => (
<label key={group} className="flex items-center space-x-3 p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
<input
type="checkbox"
className="rounded border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 focus:ring-gray-500 dark:focus:ring-gray-400"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{group}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-auto">
{Math.floor(Math.random() * 50) + 10} members
</span>
</label>
))}
</div>
</div>
)}
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
<Shield size={14} />
<span>Your privacy settings can be changed after posting</span>
</div>
</div>
</div>
);
};
export default ShareSettings;

View File

@ -0,0 +1,156 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, Settings, Edit3, Bell, Shield, LogOut, ChevronDown } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
interface UserDropdownProps {
onEditProfile: () => void;
onPreferences: () => void;
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onEditProfile, onPreferences }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useTheme();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const menuItems = [
{
icon: User,
label: 'View Profile',
action: () => {
console.log('View Profile clicked');
setIsOpen(false);
},
description: 'See your public profile'
},
{
icon: Edit3,
label: 'Edit Profile',
action: () => {
onEditProfile();
setIsOpen(false);
},
description: 'Update your information'
},
{
icon: Settings,
label: 'Preferences',
action: () => {
onPreferences();
setIsOpen(false);
},
description: 'Customize your experience'
},
{
icon: Bell,
label: 'Notifications',
action: () => {
console.log('Notifications clicked');
setIsOpen(false);
},
description: 'Manage notification settings'
},
{
icon: Shield,
label: 'Privacy & Security',
action: () => {
console.log('Privacy & Security clicked');
setIsOpen(false);
},
description: 'Control your privacy settings'
}
];
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-all duration-200 p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 group"
title="User menu"
>
<div className="w-8 h-8 rounded-full bg-gray-900 dark:bg-white flex items-center justify-center text-white dark:text-gray-900 font-medium group-hover:scale-105 transition-transform duration-200">
<User size={16} />
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50 animate-in slide-in-from-top-2 duration-200">
{/* User Info Header */}
<div className="p-4 border-b border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-full bg-gray-900 dark:bg-white flex items-center justify-center text-white dark:text-gray-900 font-medium text-lg">
Y
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">You</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">you@example.com</p>
</div>
</div>
</div>
{/* Menu Items */}
<div className="py-2">
{menuItems.map((item, index) => {
const Icon = item.icon;
return (
<button
key={index}
onClick={item.action}
className="w-full flex items-center space-x-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors group"
>
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-200 dark:group-hover:bg-gray-600 transition-colors">
<Icon size={18} className="text-gray-600 dark:text-gray-400" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{item.label}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{item.description}</div>
</div>
</button>
);
})}
</div>
{/* Separator */}
<div className="border-t border-gray-100 dark:border-gray-700"></div>
{/* Sign Out */}
<div className="py-2">
<button
onClick={() => {
console.log('Sign out clicked');
setIsOpen(false);
}}
className="w-full flex items-center space-x-3 px-4 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors group"
>
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center group-hover:bg-red-100 dark:group-hover:bg-red-900/30 transition-colors">
<LogOut size={18} className="text-gray-600 dark:text-gray-400 group-hover:text-red-600 dark:group-hover:text-red-400" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white group-hover:text-red-600 dark:group-hover:text-red-400">Sign Out</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Sign out of your account</div>
</div>
</button>
</div>
</div>
)}
</div>
);
};
export default UserDropdown;

View File

@ -0,0 +1,66 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
interface ThemeContextType {
isDarkMode: boolean;
toggleDarkMode: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isDarkMode, setIsDarkMode] = useState(() => {
// Check if we're in the browser
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('magpie-dark-mode');
if (saved !== null) {
return JSON.parse(saved);
}
// Default to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
});
// Apply theme function
const applyTheme = (darkMode: boolean) => {
const root = document.documentElement;
if (darkMode) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
console.log('Theme applied:', darkMode ? 'dark' : 'light', 'Classes:', root.classList.toString());
};
// Apply theme immediately on mount
useEffect(() => {
applyTheme(isDarkMode);
}, []);
// Handle theme changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('magpie-dark-mode', JSON.stringify(isDarkMode));
applyTheme(isDarkMode);
}
}, [isDarkMode]);
const toggleDarkMode = () => {
console.log('Toggling dark mode from', isDarkMode, 'to', !isDarkMode);
setIsDarkMode(prev => !prev);
};
return (
<ThemeContext.Provider value={{ isDarkMode, toggleDarkMode }}>
{children}
</ThemeContext.Provider>
);
};

3
src/index.css Normal file
View File

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

29
src/main.tsx Normal file
View File

@ -0,0 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
// Ensure theme is applied immediately to prevent flash
const applyInitialTheme = () => {
const savedTheme = localStorage.getItem('magpie-dark-mode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDarkMode = savedTheme ? JSON.parse(savedTheme) : prefersDark;
const root = document.documentElement;
if (isDarkMode) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
console.log('Initial theme applied:', isDarkMode ? 'dark' : 'light', 'Classes:', root.classList.toString());
};
// Apply theme before React renders
applyInitialTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

49
src/types/index.ts Normal file
View File

@ -0,0 +1,49 @@
export interface Post {
id: string;
title: string;
content: string;
media?: MediaFile[];
author: {
name: string;
avatar: string;
};
timestamp: Date;
scheduledFor?: Date;
isScheduled: boolean;
likes: number;
isLiked: boolean;
sharing: SharingSettings;
comments: Comment[];
}
export interface MediaFile {
id: string;
type: 'image' | 'video';
url: string;
filename: string;
position?: number; // Position in content for inline media
}
export interface SharingSettings {
type: 'private' | 'groups' | 'public';
specificUsers?: string[];
groups?: string[];
description?: string;
}
export interface Comment {
id: string;
content: string;
author: {
name: string;
avatar: string;
};
timestamp: Date;
}
export interface ContentBlock {
id: string;
type: 'text' | 'image' | 'video';
content: string;
mediaFile?: MediaFile;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

24
tailwind.config.js Normal file
View File

@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
}
}
},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

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

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
},
build: {
sourcemap: true
}
});