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