Initial commit
This commit is contained in:
commit
76d3c574c1
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
||||||
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal 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
25
.gitignore
vendored
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
4051
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/ChatGPT Image Jun 7, 2025, 05_00_50 PM.png
Normal file
BIN
public/ChatGPT Image Jun 7, 2025, 05_00_50 PM.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
39
public/magpie-logo.svg
Normal file
39
public/magpie-logo.svg
Normal 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
47
src/App.tsx
Normal 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;
|
||||||
341
src/components/CreatePost.tsx
Normal file
341
src/components/CreatePost.tsx
Normal 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;
|
||||||
185
src/components/EditProfileModal.tsx
Normal file
185
src/components/EditProfileModal.tsx
Normal 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;
|
||||||
58
src/components/FileUpload.tsx
Normal file
58
src/components/FileUpload.tsx
Normal 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;
|
||||||
133
src/components/Navigation.tsx
Normal file
133
src/components/Navigation.tsx
Normal 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
233
src/components/PostCard.tsx
Normal 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;
|
||||||
39
src/components/PostFeed.tsx
Normal file
39
src/components/PostFeed.tsx
Normal 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;
|
||||||
292
src/components/PreferencesModal.tsx
Normal file
292
src/components/PreferencesModal.tsx
Normal 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;
|
||||||
117
src/components/ScheduleSettings.tsx
Normal file
117
src/components/ScheduleSettings.tsx
Normal 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;
|
||||||
139
src/components/ShareSettings.tsx
Normal file
139
src/components/ShareSettings.tsx
Normal 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;
|
||||||
156
src/components/UserDropdown.tsx
Normal file
156
src/components/UserDropdown.tsx
Normal 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;
|
||||||
66
src/contexts/ThemeContext.tsx
Normal file
66
src/contexts/ThemeContext.tsx
Normal 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
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
29
src/main.tsx
Normal file
29
src/main.tsx
Normal 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
49
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
24
tailwind.config.js
Normal file
24
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
14
vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user