diff --git a/package.json b/package.json index fda3351..9bf0eaf 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "typescript": "~3.7.2" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", + "start": "set NODE_OPTIONS=--openssl-legacy-provider && react-scripts start", + "build": "set NODE_OPTIONS=--openssl-legacy-provider && react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/src/App.tsx b/src/App.tsx index 1880d7c..50150b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,13 +2,14 @@ import React from 'react'; import Layout from './components/Layout'; import GlobalStyles from './styles/GlobalStyles'; +import { ThemeProvider } from './contexts/ThemeContext'; function App() { return ( - <> + - > + ); } diff --git a/src/components/Comments/index.tsx b/src/components/Comments/index.tsx new file mode 100644 index 0000000..f6d375c --- /dev/null +++ b/src/components/Comments/index.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { + Container, + CommentItem, + Avatar, + Content, + Header, + Author, + Time, + Text, + CommentForm, + TextArea, + SubmitButton, + ShowCommentsButton, + CommentsCount, +} from './styles'; + +interface Comment { + id: string; + author: string; + avatar: string; + text: string; + time: string; +} + +interface CommentsProps { + tweetId: string; + commentsCount: number; + onCommentAdd: (tweetId: string, comment: Comment) => void; + comments: Comment[]; + isExpanded: boolean; + onToggleComments: () => void; +} + +const Comments: React.FC = ({ + tweetId, + commentsCount, + onCommentAdd, + comments, + isExpanded, + onToggleComments, +}) => { + const [commentText, setCommentText] = useState(''); + const [showForm, setShowForm] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (commentText.trim()) { + const newComment: Comment = { + id: Date.now().toString(), + author: 'Elton Lazzarin', + avatar: 'https://avatars1.githubusercontent.com/u/53025782?s=400&u=f1ffa8eaccb8545222b7c642532161f11e74a03d&v=4', + text: commentText.trim(), + time: 'now', + }; + + onCommentAdd(tweetId, newComment); + setCommentText(''); + setShowForm(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + handleSubmit(e); + } + }; + + return ( + + {commentsCount > 0 && ( + + + {isExpanded ? 'Hide' : 'Show'} {commentsCount} comment{commentsCount !== 1 ? 's' : ''} + + + )} + + {isExpanded && ( + <> + {comments.map((comment) => ( + + + + + + + {comment.author} + {comment.time} + + {comment.text} + + + ))} + > + )} + + {showForm ? ( + + + + + + ) => setCommentText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Tweet your reply" + autoFocus + /> + + { + setShowForm(false); + setCommentText(''); + }} + style={{ + background: 'transparent', + border: '1px solid var(--outline)', + color: 'var(--white)', + padding: '6px 16px', + borderRadius: '20px', + cursor: 'pointer', + }} + > + Cancel + + + Reply + + + + + ) : ( + setShowForm(true)}> + Add a comment... + + )} + + ); +}; + +export default Comments; \ No newline at end of file diff --git a/src/components/Comments/styles.ts b/src/components/Comments/styles.ts new file mode 100644 index 0000000..c309554 --- /dev/null +++ b/src/components/Comments/styles.ts @@ -0,0 +1,127 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + margin-top: 8px; +`; + +export const ShowCommentsButton = styled.button` + background: transparent; + border: none; + color: var(--gray); + cursor: pointer; + padding: 8px 0; + font-size: 13px; + display: flex; + align-items: center; + width: 100%; + text-align: left; + + &:hover { + color: var(--twitter); + } +`; + +export const CommentsCount = styled.span` + font-size: 13px; +`; + +export const CommentItem = styled.div` + display: flex; + padding: 12px 0; + border-top: 1px solid var(--outline); + + &:first-child { + border-top: none; + } +`; + +export const Avatar = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + overflow: hidden; + margin-right: 12px; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +export const Content = styled.div` + flex: 1; + display: flex; + flex-direction: column; +`; + +export const Header = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +`; + +export const Author = styled.span` + font-weight: bold; + font-size: 14px; + color: var(--white); +`; + +export const Time = styled.span` + font-size: 13px; + color: var(--gray); +`; + +export const Text = styled.p` + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--white); +`; + +export const CommentForm = styled.form` + display: flex; + padding: 12px 0; + border-top: 1px solid var(--outline); + margin-top: 8px; +`; + +export const TextArea = styled.textarea` + width: 100%; + background: transparent; + border: none; + outline: none; + font-size: 14px; + color: var(--white); + resize: none; + min-height: 40px; + font-family: inherit; + line-height: 1.4; + + &::placeholder { + color: var(--gray); + } +`; + +export const SubmitButton = styled.button` + background-color: var(--twitter); + color: white; + border: none; + border-radius: 20px; + padding: 6px 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.2s; + font-size: 13px; + + &:hover:not(:disabled) { + background-color: #1a8cd8; + } + + &:disabled { + background-color: rgba(29, 161, 242, 0.5); + cursor: not-allowed; + } +`; \ No newline at end of file diff --git a/src/components/Feed/index.tsx b/src/components/Feed/index.tsx index 33acb6e..8bfe071 100644 --- a/src/components/Feed/index.tsx +++ b/src/components/Feed/index.tsx @@ -1,16 +1,87 @@ -import React from 'react'; +import React, { useState } from 'react'; import Tweet from '../Tweet'; +import TweetComposer from '../TweetComposer'; +import SearchBar from '../SearchBar'; +import SearchResults from '../SearchResults'; +import data from '../../data.json'; +import { TweetType } from '../../types'; -import { Container, Tab, Tweets } from './styles'; +import { Container, Tab, Tweets, SearchSection } from './styles'; const Feed: React.FC = () => { + const [tweets, setTweets] = useState( + data.map((tweet, index) => ({ + ...tweet, + id: `existing-${index}`, // Добавляем уникальные ID для существующих твитов + comments: [] + })) + ); + + const [isSearchMode, setIsSearchMode] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const handleNewTweet = (newTweetData: { text: string; image?: string }) => { + const newTweet: TweetType = { + id: Date.now().toString(), + avatar: "https://avatars1.githubusercontent.com/u/53025782?s=400&u=f1ffa8eaccb8545222b7c642532161f11e74a03d&v=4", + author: "Elton Lazzarin", + twitteruser: "@elton_lazzarin", + posttime: "now", + posttext: newTweetData.text, + postimage: newTweetData.image, + commentscount: 0, + retweetscount: 0, + likecount: 0, + retweet: false, + comments: [], + }; + + setTweets(prevTweets => [newTweet, ...prevTweets]); + }; + + const handleUpdateTweet = (tweetId: string, updatedData: Partial) => { + setTweets(prevTweets => + prevTweets.map(tweet => + tweet.id === tweetId ? { ...tweet, ...updatedData } : tweet + ) + ); + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + setIsSearchMode(true); + }; + + const handleBackToFeed = () => { + setIsSearchMode(false); + setSearchQuery(''); + }; + + // Если в режиме поиска, показываем результаты поиска + if (isSearchMode) { + return ( + + ); + } + return ( - Tweets + Home + + + + + + - + ); diff --git a/src/components/Feed/styles.ts b/src/components/Feed/styles.ts index 0a92a44..4506287 100644 --- a/src/components/Feed/styles.ts +++ b/src/components/Feed/styles.ts @@ -24,6 +24,15 @@ export const Tab = styled.div` } `; +export const SearchSection = styled.div` + padding: 16px; + border-bottom: 1px solid var(--border); + background: var(--primary); + position: sticky; + top: 53px; + z-index: 100; +`; + export const Tweets = styled.div` display: flex; flex-direction: column; diff --git a/src/components/Main/index.tsx b/src/components/Main/index.tsx index 5bf5ce5..f71294f 100644 --- a/src/components/Main/index.tsx +++ b/src/components/Main/index.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useState } from 'react'; import ProfilePage from '../ProfilePage'; +import Feed from '../Feed'; import { Container, @@ -15,23 +16,41 @@ import { } from './styles'; const Main: React.FC = () => { + const [currentView, setCurrentView] = useState<'home' | 'profile'>('home'); + + const renderContent = () => { + switch (currentView) { + case 'home': + return ; + case 'profile': + return ; + default: + return ; + } + }; + return ( - - - - + {currentView === 'profile' && ( + + setCurrentView('home')}> + + - - Elton Lazzarin - 432 Tweets - - + + Elton Lazzarin + 432 Tweets + + + )} - + {renderContent()} - + setCurrentView('home')} + /> diff --git a/src/components/MenuBar/index.tsx b/src/components/MenuBar/index.tsx index ed4ead8..0d49e1c 100644 --- a/src/components/MenuBar/index.tsx +++ b/src/components/MenuBar/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Button from '../Button'; +import ThemeToggle from '../ThemeToggle'; import { Container, @@ -64,6 +65,8 @@ const MenuBar: React.FC = () => { Tweet + + diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx new file mode 100644 index 0000000..67da6f1 --- /dev/null +++ b/src/components/SearchBar/index.tsx @@ -0,0 +1,123 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + Container, + SearchContainer, + SearchIcon, + SearchInput, + ClearButton, + ClearIcon, + TrendingSection, + TrendingTitle, + TrendingItem, + TrendingHashtag, + TrendingCount, + ShowMoreButton, +} from './styles'; + +interface SearchBarProps { + onSearch: (query: string) => void; + placeholder?: string; + autoFocus?: boolean; +} + +const SearchBar: React.FC = ({ + onSearch, + placeholder = "Search Twitter", + autoFocus = false, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + + // Трендовые хэштеги и темы + const trendingTopics = [ + { hashtag: '#ReactJS', count: '125K Tweets' }, + { hashtag: '#TypeScript', count: '85K Tweets' }, + { hashtag: '#JavaScript', count: '450K Tweets' }, + { hashtag: '#WebDevelopment', count: '75K Tweets' }, + { hashtag: '#Programming', count: '200K Tweets' }, + { hashtag: '#OpenSource', count: '95K Tweets' }, + { hashtag: '#TechNews', count: '180K Tweets' }, + { hashtag: '#AI', count: '320K Tweets' }, + ]; + + useEffect(() => { + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + } + }, [autoFocus]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchQuery(value); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + onSearch(searchQuery.trim()); + } + }; + + const handleClear = () => { + setSearchQuery(''); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const handleTrendingClick = (hashtag: string) => { + setSearchQuery(hashtag); + onSearch(hashtag); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(e); + } else if (e.key === 'Escape') { + handleClear(); + setIsFocused(false); + } + }; + + return ( + + + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyDown={handleKeyDown} + /> + {searchQuery && ( + + + + )} + + + {isFocused && !searchQuery && ( + + Trending + {trendingTopics.slice(0, 5).map((topic, index) => ( + handleTrendingClick(topic.hashtag)} + > + {topic.hashtag} + {topic.count} + + ))} + Show more + + )} + + ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/src/components/SearchBar/styles.ts b/src/components/SearchBar/styles.ts new file mode 100644 index 0000000..a56ce25 --- /dev/null +++ b/src/components/SearchBar/styles.ts @@ -0,0 +1,131 @@ +import styled from 'styled-components'; +import { Search, X } from '@styled-icons/feather'; + +export const Container = styled.div` + position: relative; + width: 100%; +`; + +export const SearchContainer = styled.div<{ isFocused: boolean }>` + display: flex; + align-items: center; + background: var(--search); + border: 1px solid ${props => props.isFocused ? 'var(--twitter)' : 'transparent'}; + border-radius: 25px; + padding: 12px 16px; + transition: all 0.2s; + position: relative; + + &:hover { + border-color: var(--twitter); + } +`; + +export const SearchIcon = styled(Search)` + width: 18px; + height: 18px; + color: var(--secondary-text); + margin-right: 12px; + flex-shrink: 0; +`; + +export const SearchInput = styled.input` + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: 15px; + color: var(--primary-text); + font-family: inherit; + + &::placeholder { + color: var(--secondary-text); + } +`; + +export const ClearButton = styled.button` + background: var(--twitter); + border: none; + border-radius: 50%; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-left: 8px; + transition: background-color 0.2s; + + &:hover { + background: var(--twitter-dark-hover); + } +`; + +export const ClearIcon = styled(X)` + width: 12px; + height: 12px; + color: white; +`; + +export const TrendingSection = styled.div` + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--primary); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + z-index: 1000; + margin-top: 8px; + overflow: hidden; +`; + +export const TrendingTitle = styled.div` + padding: 16px 16px 8px; + font-size: 20px; + font-weight: 800; + color: var(--primary-text); +`; + +export const TrendingItem = styled.div` + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--border); + + &:hover { + background: var(--hover); + } + + &:last-child { + border-bottom: none; + } +`; + +export const TrendingHashtag = styled.div` + font-size: 15px; + font-weight: 700; + color: var(--primary-text); + margin-bottom: 2px; +`; + +export const TrendingCount = styled.div` + font-size: 13px; + color: var(--secondary-text); +`; + +export const ShowMoreButton = styled.button` + width: 100%; + background: transparent; + border: none; + padding: 16px; + color: var(--twitter); + font-size: 15px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: var(--hover); + } +`; \ No newline at end of file diff --git a/src/components/SearchResults/index.tsx b/src/components/SearchResults/index.tsx new file mode 100644 index 0000000..c3e3d93 --- /dev/null +++ b/src/components/SearchResults/index.tsx @@ -0,0 +1,168 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import Tweet from '../Tweet'; +import { TweetType, Comment } from '../../types'; +import { + Container, + Header, + BackButton, + BackIcon, + SearchInfo, + ResultsCount, + FilterTabs, + FilterTab, + NoResults, + NoResultsIcon, + NoResultsText, +} from './styles'; + +interface SearchResultsProps { + searchQuery: string; + tweets: TweetType[]; + onUpdateTweet: (tweetId: string, updatedData: Partial) => void; + onBackToFeed: () => void; +} + +type FilterType = 'all' | 'hashtags' | 'mentions'; + +const SearchResults: React.FC = ({ + searchQuery, + tweets, + onUpdateTweet, + onBackToFeed, +}) => { + const [activeFilter, setActiveFilter] = useState('all'); + + // Функция для поиска твитов + const filteredTweets = useMemo(() => { + if (!searchQuery.trim()) return []; + + const query = searchQuery.toLowerCase().trim(); + + return tweets.filter(tweet => { + // Конвертируем posttext в строку для поиска + const tweetText = typeof tweet.posttext === 'string' + ? tweet.posttext.toLowerCase() + : Array.isArray(tweet.posttext) + ? tweet.posttext.join('').toLowerCase() + : tweet.posttext.toString().toLowerCase(); + + const author = tweet.author.toLowerCase(); + const username = tweet.twitteruser.toLowerCase(); + + switch (activeFilter) { + case 'hashtags': + // Поиск только по хэштегам + if (query.startsWith('#')) { + return tweetText.includes(query); + } + // Если запрос не начинается с #, ищем хэштеги содержащие запрос + const hashtagRegex = new RegExp(`#\\w*${query.replace('#', '')}\\w*`, 'gi'); + return hashtagRegex.test(tweetText); + + case 'mentions': + // Поиск только по упоминаниям + if (query.startsWith('@')) { + return tweetText.includes(query) || username.includes(query); + } + // Если запрос не начинается с @, ищем упоминания содержащие запрос + const mentionRegex = new RegExp(`@\\w*${query.replace('@', '')}\\w*`, 'gi'); + return mentionRegex.test(tweetText) || author.includes(query); + + case 'all': + default: + // Поиск по всему тексту, автору и упоминаниям + return ( + tweetText.includes(query) || + author.includes(query) || + username.includes(query) + ); + } + }); + }, [tweets, searchQuery, activeFilter]); + + // Функция для подсветки найденного текста + const highlightText = (text: string, query: string) => { + if (!query.trim()) return text; + + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : part + ); + }; + + // Обновленные твиты с подсветкой + const highlightedTweets = useMemo(() => { + return filteredTweets.map(tweet => ({ + ...tweet, + posttext: typeof tweet.posttext === 'string' + ? highlightText(tweet.posttext, searchQuery) + : tweet.posttext, // Если уже JSX, оставляем как есть + })); + }, [filteredTweets, searchQuery]); + + const getFilterLabel = (filter: FilterType) => { + switch (filter) { + case 'all': return `All (${filteredTweets.length})`; + case 'hashtags': return `Hashtags (${filteredTweets.length})`; + case 'mentions': return `People (${filteredTweets.length})`; + default: return ''; + } + }; + + return ( + + + + + + + Search results + + {filteredTweets.length} results for "{searchQuery}" + + + + + + setActiveFilter('all')} + > + {getFilterLabel('all')} + + setActiveFilter('hashtags')} + > + {getFilterLabel('hashtags')} + + setActiveFilter('mentions')} + > + {getFilterLabel('mentions')} + + + + {filteredTweets.length === 0 ? ( + + + + No results for "{searchQuery}" + Try searching for something else. + + + ) : ( + + )} + + ); +}; + +export default SearchResults; \ No newline at end of file diff --git a/src/components/SearchResults/styles.ts b/src/components/SearchResults/styles.ts new file mode 100644 index 0000000..356ea38 --- /dev/null +++ b/src/components/SearchResults/styles.ts @@ -0,0 +1,129 @@ +import styled from 'styled-components'; +import { ArrowLeft, Search } from '@styled-icons/feather'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + min-height: 100vh; +`; + +export const Header = styled.div` + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--primary); + position: sticky; + top: 0; + z-index: 999; +`; + +export const BackButton = styled.button` + background: transparent; + border: none; + padding: 8px; + border-radius: 50%; + cursor: pointer; + margin-right: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + + &:hover { + background: var(--hover); + } +`; + +export const BackIcon = styled(ArrowLeft)` + width: 20px; + height: 20px; + color: var(--twitter); +`; + +export const SearchInfo = styled.div` + display: flex; + flex-direction: column; + + strong { + font-size: 20px; + font-weight: bold; + color: var(--primary-text); + } +`; + +export const ResultsCount = styled.span` + font-size: 13px; + color: var(--secondary-text); + margin-top: 2px; +`; + +export const FilterTabs = styled.div` + display: flex; + border-bottom: 1px solid var(--border); + background: var(--primary); + position: sticky; + top: 73px; + z-index: 998; +`; + +export const FilterTab = styled.button<{ isActive: boolean }>` + flex: 1; + background: transparent; + border: none; + padding: 16px; + font-size: 15px; + font-weight: ${props => props.isActive ? '700' : '400'}; + color: ${props => props.isActive ? 'var(--twitter)' : 'var(--secondary-text)'}; + cursor: pointer; + position: relative; + transition: all 0.2s; + + &:hover { + background: var(--hover); + color: var(--twitter); + } + + ${props => props.isActive && ` + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--twitter); + } + `} +`; + +export const NoResults = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 32px; + text-align: center; +`; + +export const NoResultsIcon = styled(Search)` + width: 64px; + height: 64px; + color: var(--secondary-text); + margin-bottom: 24px; +`; + +export const NoResultsText = styled.div` + h3 { + font-size: 31px; + font-weight: 800; + color: var(--primary-text); + margin-bottom: 8px; + } + + p { + font-size: 15px; + color: var(--secondary-text); + line-height: 20px; + } +`; \ No newline at end of file diff --git a/src/components/SideBar/index.tsx b/src/components/SideBar/index.tsx index 7448fb7..0921b60 100644 --- a/src/components/SideBar/index.tsx +++ b/src/components/SideBar/index.tsx @@ -4,6 +4,7 @@ import StickyBox from 'react-sticky-box'; import List from '../List'; import FollowSuggestion from '../FollowSuggestion'; import News from '../News'; +import ThemeToggle from '../ThemeToggle'; import { Container, @@ -48,6 +49,8 @@ const SideBar: React.FC = () => { , ]} /> + +
Try searching for something else.