diff --git a/twitter/README.md b/twitter/README.md new file mode 100644 index 00000000..bc127b67 --- /dev/null +++ b/twitter/README.md @@ -0,0 +1,328 @@ +# PySocial - Twitter Clone with Reflex + +A fully functional Twitter clone built with Reflex, featuring user authentication, tweets, profiles, and social interactions. + +## 🚀 Features + +### Core Functionality +- **User Authentication**: Sign up, login, and logout +- **Tweet Management**: Create, edit, and delete tweets +- **User Profiles**: Customizable profiles with photos and information +- **Social Features**: Follow/unfollow users and view followers +- **Search**: Search for tweets and users +- **Like System**: Like and unlike tweets (database ready) + +### Recent Updates & Enhancements + +#### 1. Enhanced UI with Twitter Color Scheme +- **Twitter Blue** (#1DA1F2) for primary actions and branding +- **Dark Gray** (#14171A) for primary text +- **Light Gray** (#657786) for secondary text +- **Border Color** (#E1E8ED) matching Twitter's design +- Beautiful gradient backgrounds and modern shadows +- Smooth hover effects and transitions + +#### 2. Tweet CRUD Operations +- ✅ **Create**: Post new tweets with a clean composer interface +- ✅ **Read**: View tweets in a feed with author information +- ✅ **Update**: Edit your own tweets inline with a pencil icon +- ✅ **Delete**: Remove your tweets with a trash icon +- Auto-clear tweet input after posting +- Only tweet authors can edit/delete their own tweets + +#### 3. Profile Management System +- **Profile Photos**: Upload and display profile pictures +- **Editable Fields**: + - Display Name (different from username) + - Bio (tell us about yourself) + - Location + - Website +- **Profile Header**: Gradient cover photo with profile picture overlay +- **Edit Mode**: Toggle between view and edit modes +- **Default Avatar**: User icon for accounts without photos + +#### 4. Like/Favorite System (Database Layer) +- `Like` table implemented for tracking tweet likes +- Composite primary key (tweet_id + username) +- Ready for frontend integration + +## 📁 Project Structure + +``` +twitter/ +├── db_model.py # Database models (User, Tweet, Follows, Like) +├── state/ +│ ├── base.py # Base state with authentication +│ ├── home.py # Home feed state with tweet CRUD +│ └── profile.py # Profile editing state +├── components/ +│ ├── profile.py # Profile page components +│ └── tweet.py # Tweet display components +├── pages/ +│ └── home.py # Home page with feed +├── layouts/ +│ └── auth.py # Authentication layout +└── __init__.py # App initialization and routes +``` + +## 🗄️ Database Schema + +### User Table +- `username`: Primary identifier +- `password`: Hashed password +- `profile_photo`: URL or path to profile image +- `bio`: User biography +- `display_name`: Display name (can differ from username) +- `location`: User location +- `website`: User website URL + +### Tweet Table +- `id`: Auto-generated primary key +- `content`: Tweet text +- `created_at`: Timestamp +- `author`: Username of tweet creator + +### Follows Table (Many-to-Many) +- `follower_username`: User who follows +- `followed_username`: User being followed + +### Like Table (Many-to-Many) +- `tweet_id`: ID of liked tweet +- `username`: User who liked the tweet + +## 🎨 UI/UX Features + +### Authentication Page +- Gradient background (Twitter Blue to Dark) +- Clean white card with rounded corners +- Modern shadows and spacing +- Responsive design + +### Home Feed +- Tweet composer with character count +- Real-time tweet feed +- Edit mode with inline editing +- Delete confirmation +- Twitter-style avatars and layouts + +### Profile Page +- Cover photo with gradient +- Circular profile picture +- Editable profile information +- Drag-and-drop photo upload +- Save/Cancel actions with feedback + +## 🛠️ Setup Instructions + +1. **Install dependencies**: + ```bash + pip install reflex + ``` + +2. **Create upload directory**: + ```bash + mkdir -p uploaded_files + ``` + +3. **Initialize database**: + ```bash + reflex db makemigrations + reflex db migrate + ``` + +4. **Run the application**: + ```bash + reflex run + ``` + +5. **Access the app**: + - Open your browser to `http://localhost:3000` + - Sign up for a new account + - Start tweeting! + +## 📝 Usage + +### Posting Tweets +1. Type your message in the composer +2. Click "Tweet" button +3. Tweet appears in feed and input clears automatically + +### Editing Tweets +1. Find your tweet in the feed +2. Click the pencil icon +3. Edit the content inline +4. Click "Save" or "Cancel" + +### Deleting Tweets +1. Find your tweet in the feed +2. Click the trash icon +3. Tweet is removed immediately + +### Editing Profile +1. Navigate to `/profile` +2. Click "Edit Profile" button +3. Upload a photo or update your information +4. Click "Save Changes" + +## 🎯 Future Enhancements + +- [ ] Implement like button UI and counter +- [ ] Add retweet functionality +- [ ] Implement direct messaging +- [ ] Add notifications system +- [ ] Media upload for tweets +- [ ] Hashtag support +- [ ] Trending topics +- [ ] User mentions (@username) +- [ ] Tweet replies/threads + +## 🎨 Color Palette + +- **Primary Blue**: `#1DA1F2` (Zima Blue) +- **Primary Blue Hover**: `#1A91DA` +- **Dark Text**: `#14171A` +- **Secondary Text**: `#657786` +- **Border**: `#E1E8ED` +- **Background**: `#F7F9FA` +- **White**: `#FFFFFF` +- **Error Red**: `#E0245E` + +## 📋 Changelog - What Was Changed + +### 🎨 UI Enhancements (`layouts/auth.py`) +**Before**: Basic auth layout with generic styling +**After**: Twitter-themed authentication page +- Added Zima Blue (#1DA1F2) color scheme throughout +- Implemented gradient background (Twitter Blue to Dark) +- Enhanced heading styles with proper colors and sizing +- Improved box shadows and border radius for modern look +- Added hover effects on links with color transitions +- Updated spacing and padding for better visual hierarchy + +### 🗄️ Database Model Extensions (`db_model.py`) +**Added to User Model**: +```python +profile_photo: str = "" # URL or path to profile photo +bio: str = "" # User bio/description +display_name: str = "" # Display name (different from username) +location: str = "" # User location +website: str = "" # User website +``` + +**New Like Table**: +```python +class Like(rx.Model, table=True): + tweet_id: int = Field(primary_key=True) + username: str = Field(primary_key=True) +``` +- Enables like/favorite functionality +- Composite primary key prevents duplicate likes +- Ready for frontend integration + +### ✏️ Tweet CRUD Operations (`state/home.py`) +**Modified `post_tweet()` method**: +- Added `self.tweet = ""` to clear input after posting +- Ensures clean UI experience after tweeting + +**Added New Methods**: +```python +delete_tweet(tweet_id: int) # Delete a tweet +start_edit_tweet(tweet_id, content) # Enter edit mode +cancel_edit_tweet() # Cancel editing +save_edit_tweet(tweet_id) # Save edited tweet +``` + +**Added State Variables**: +```python +editing_tweet_id: int = -1 # Track which tweet is being edited +edit_tweet_content: str = "" # Store edit content +``` + +### 🏠 Home Page Improvements (`pages/home.py`) +**Updated `composer()` function**: +- Changed from `on_blur` to `on_change` with `value` binding +- Added `value=HomeState.tweet` for proper state binding +- Added `disabled=HomeState.tweet == ""` to prevent empty tweets +- Enhanced button styling with `color_scheme="blue"` + +**Enhanced `tweet()` display function**: +- Added conditional rendering for edit/view modes +- **View Mode Features**: + - Edit button (pencil icon) for tweet authors + - Delete button (trash icon) for tweet authors + - Buttons only visible to tweet owner + - Icon buttons with hover effects +- **Edit Mode Features**: + - Inline text area for editing + - Save/Cancel action buttons + - Color-coded buttons (blue for save, outline for cancel) + +### 👤 Profile Management System (New Files) + +**Created `state/profile.py`**: +- `ProfileState` class with profile management logic +- Form fields: display_name, bio, location, website, profile_photo +- UI state: is_editing, upload_progress +- Methods: + - `load_profile_data()`: Load current user's profile + - `toggle_edit_mode()`: Switch between view and edit + - `update_profile()`: Save profile changes to database + - `handle_upload()`: Process profile photo uploads + - `cancel_edit()`: Discard changes + +**Created `components/profile.py`**: +- `profile_header()`: Cover photo with gradient + profile picture +- `edit_profile_form()`: Complete profile editing interface + - Profile photo upload with drag-and-drop + - Display name input + - Bio text area + - Location input + - Website input + - Save/Cancel buttons +- `profile_info()`: Display profile information + - Shows display name or username + - Displays bio, location, website with icons + - Edit Profile button +- `profile_page()`: Main profile page layout + +**Updated `__init__.py`**: +- Added import for `profile_page` +- Added route: `app.add_page(profile_page, route="/profile", title="Profile")` + +**Updated `components/__init__.py`**: +- Added export: `from .profile import profile_page` + +### 🎨 Design System Applied +All components now follow Twitter's official design language: +- **Buttons**: Rounded (border-radius: 20px) with Twitter Blue +- **Inputs**: Light borders (#E1E8ED) with blue focus states +- **Cards**: White backgrounds with subtle shadows +- **Spacing**: Consistent padding and margins +- **Typography**: Bold headings in dark gray, secondary text in light gray +- **Icons**: Size-consistent with proper colors and hover states +- **Hover Effects**: Smooth transitions on interactive elements + +### 📂 New Directory Structure +``` +uploaded_files/ # Created for profile photo storage +``` + +### 🔄 Breaking Changes +None - All changes are backward compatible with existing data + +### ⚠️ Migration Required +After pulling these changes, run: +```bash +reflex db makemigrations +reflex db migrate +``` + +This will add the new fields to User table and create the Like table. + +## 🤝 Contributing + +Feel free to fork this project and add your own features! + +## 📄 License + +This is a demo project for learning Reflex framework. diff --git a/twitter/alembic.ini b/twitter/alembic.ini new file mode 100644 index 00000000..07918167 --- /dev/null +++ b/twitter/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/twitter/alembic/env.py b/twitter/alembic/env.py new file mode 100644 index 00000000..36112a3c --- /dev/null +++ b/twitter/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/twitter/alembic/script.py.mako b/twitter/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/twitter/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/twitter/alembic/versions/b4f29dafaf7c_.py b/twitter/alembic/versions/b4f29dafaf7c_.py new file mode 100644 index 00000000..828c143d --- /dev/null +++ b/twitter/alembic/versions/b4f29dafaf7c_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: b4f29dafaf7c +Revises: cd0c41e31106 +Create Date: 2025-10-02 15:15:26.603719 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = 'b4f29dafaf7c' +down_revision: Union[str, Sequence[str], None] = 'cd0c41e31106' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('like', + sa.Column('tweet_id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('tweet_id', 'username') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('profile_photo', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + batch_op.add_column(sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + batch_op.add_column(sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + batch_op.add_column(sa.Column('location', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + batch_op.add_column(sa.Column('website', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('website') + batch_op.drop_column('location') + batch_op.drop_column('display_name') + batch_op.drop_column('bio') + batch_op.drop_column('profile_photo') + + op.drop_table('like') + # ### end Alembic commands ### diff --git a/twitter/alembic/versions/cd0c41e31106_.py b/twitter/alembic/versions/cd0c41e31106_.py new file mode 100644 index 00000000..2db4fc77 --- /dev/null +++ b/twitter/alembic/versions/cd0c41e31106_.py @@ -0,0 +1,51 @@ +"""empty message + +Revision ID: cd0c41e31106 +Revises: +Create Date: 2025-10-02 15:05:19.716780 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = 'cd0c41e31106' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('follows', + sa.Column('followed_username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('follower_username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('followed_username', 'follower_username') + ) + op.create_table('tweet', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('author', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user') + op.drop_table('tweet') + op.drop_table('follows') + # ### end Alembic commands ### diff --git a/twitter/twitter/__init__.py b/twitter/twitter/__init__.py index e69de29b..0a836ca2 100644 --- a/twitter/twitter/__init__.py +++ b/twitter/twitter/__init__.py @@ -0,0 +1,18 @@ +"""Welcome to Reflex! This file outlines the steps to create a basic app.""" + +import reflex as rx + +# ...existing imports... +from .components.profile import profile_page + +# ...existing code... + +# Create app instance and add pages +app = rx.App() + +# ...existing page routes... + +# Add profile page route +app.add_page(profile_page, route="/profile", title="Profile") + +# ...existing code... \ No newline at end of file diff --git a/twitter/twitter/components/__init__.py b/twitter/twitter/components/__init__.py index 9d699ce9..4a0a62b9 100644 --- a/twitter/twitter/components/__init__.py +++ b/twitter/twitter/components/__init__.py @@ -1,3 +1,4 @@ -"""Re-export components.""" +"""Components for the app.""" from .container import container as container +from .profile import profile_page diff --git a/twitter/twitter/components/profile.py b/twitter/twitter/components/profile.py new file mode 100644 index 00000000..80d42b77 --- /dev/null +++ b/twitter/twitter/components/profile.py @@ -0,0 +1,286 @@ + +"""Profile page component.""" + +import reflex as rx +from ..state.profile import ProfileState + + +def profile_header() -> rx.Component: + """Profile header with cover photo and profile picture.""" + return rx.box( + # Cover photo area + rx.box( + height="200px", + width="100%", + background="linear-gradient(135deg, #1DA1F2 0%, #14171A 100%)", + border_radius="0", + ), + # Profile picture + rx.box( + rx.cond( + ProfileState.profile_photo != "", + rx.image( + src=ProfileState.profile_photo, + width="150px", + height="150px", + border_radius="50%", + border="4px solid white", + object_fit="cover", + ), + rx.box( + rx.icon("user", size=50, color="#657786"), + width="150px", + height="150px", + border_radius="50%", + border="4px solid white", + background="#E1E8ED", + display="flex", + align_items="center", + justify_content="center", + ), + ), + position="absolute", + top="140px", + left="20px", + ), + position="relative", + margin_bottom="60px", + ) + + +def edit_profile_form() -> rx.Component: + """Form to edit profile information.""" + return rx.box( + rx.vstack( + rx.heading("Edit Profile", size="6", color="#14171A", margin_bottom="20px"), + + # Profile photo upload + rx.vstack( + rx.text("Profile Photo", font_weight="bold", color="#14171A"), + rx.upload( + rx.vstack( + rx.button( + "Select Profile Photo", + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + ), + rx.text( + "Drag and drop or click to upload", + font_size="14px", + color="#657786", + ), + ), + id="profile_photo_upload", + border="2px dashed #E1E8ED", + padding="20px", + border_radius="10px", + ), + rx.button( + "Upload Photo", + on_click=ProfileState.handle_upload( + rx.upload_files(upload_id="profile_photo_upload") + ), + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + ), + width="100%", + spacing="2", + ), + + # Display name + rx.vstack( + rx.text("Display Name", font_weight="bold", color="#14171A"), + rx.input( + placeholder="Your display name", + value=ProfileState.display_name, + on_change=ProfileState.set_display_name, + width="100%", + border_color="#E1E8ED", + _focus={"border_color": "#1DA1F2"}, + ), + width="100%", + spacing="2", + ), + + # Bio + rx.vstack( + rx.text("Bio", font_weight="bold", color="#14171A"), + rx.text_area( + placeholder="Tell us about yourself", + value=ProfileState.bio, + on_change=ProfileState.set_bio, + width="100%", + height="100px", + border_color="#E1E8ED", + _focus={"border_color": "#1DA1F2"}, + ), + width="100%", + spacing="2", + ), + + # Location + rx.vstack( + rx.text("Location", font_weight="bold", color="#14171A"), + rx.input( + placeholder="Where are you located?", + value=ProfileState.location, + on_change=ProfileState.set_location, + width="100%", + border_color="#E1E8ED", + _focus={"border_color": "#1DA1F2"}, + ), + width="100%", + spacing="2", + ), + + # Website + rx.vstack( + rx.text("Website", font_weight="bold", color="#14171A"), + rx.input( + placeholder="https://yourwebsite.com", + value=ProfileState.website, + on_change=ProfileState.set_website, + width="100%", + border_color="#E1E8ED", + _focus={"border_color": "#1DA1F2"}, + ), + width="100%", + spacing="2", + ), + + # Action buttons + rx.hstack( + rx.button( + "Save Changes", + on_click=ProfileState.update_profile, + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + padding="10px 24px", + ), + rx.button( + "Cancel", + on_click=ProfileState.cancel_edit, + color="#14171A", + bg="white", + border="1px solid #E1E8ED", + _hover={"bg": "#F7F9FA"}, + border_radius="20px", + padding="10px 24px", + ), + spacing="3", + justify="flex-end", + width="100%", + ), + + spacing="4", + width="100%", + ), + padding="20px", + background="white", + border_radius="16px", + border="1px solid #E1E8ED", + box_shadow="0 2px 8px rgba(0, 0, 0, 0.1)", + ) + + +def profile_info() -> rx.Component: + """Display profile information.""" + return rx.vstack( + rx.hstack( + rx.vstack( + rx.cond( + ProfileState.display_name != "", + rx.heading(ProfileState.display_name, size="7", color="#14171A"), + rx.heading(ProfileState.username, size="7", color="#14171A"), + ), + rx.text(f"@{ProfileState.username}", color="#657786", font_size="16px"), + align_items="flex-start", + spacing="1", + ), + rx.spacer(), + rx.button( + "Edit Profile", + on_click=ProfileState.toggle_edit_mode, + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + padding="8px 20px", + font_weight="bold", + ), + width="100%", + align_items="center", + ), + + rx.cond( + ProfileState.bio != "", + rx.text(ProfileState.bio, color="#14171A", font_size="15px", margin_top="12px"), + ), + + rx.vstack( + rx.cond( + ProfileState.location != "", + rx.hstack( + rx.icon("map-pin", size=18, color="#657786"), + rx.text(ProfileState.location, color="#657786", font_size="15px"), + spacing="2", + ), + ), + rx.cond( + ProfileState.website != "", + rx.hstack( + rx.icon("link", size=18, color="#657786"), + rx.link( + ProfileState.website, + href=ProfileState.website, + color="#1DA1F2", + font_size="15px", + _hover={"text_decoration": "underline"}, + ), + spacing="2", + ), + ), + spacing="2", + margin_top="12px", + align_items="flex-start", + ), + + padding="20px", + width="100%", + align_items="flex-start", + spacing="2", + ) + + +def profile_page() -> rx.Component: + """Main profile page.""" + return rx.box( + rx.vstack( + profile_header(), + + rx.box( + rx.cond( + ProfileState.is_editing, + edit_profile_form(), + profile_info(), + ), + width="100%", + max_width="600px", + margin="0 auto", + ), + + width="100%", + spacing="0", + ), + padding="0", + width="100%", + background="#F7F9FA", + min_height="100vh", + on_mount=ProfileState.load_profile_data, + ) diff --git a/twitter/twitter/components/tweet.py b/twitter/twitter/components/tweet.py new file mode 100644 index 00000000..7d99c852 --- /dev/null +++ b/twitter/twitter/components/tweet.py @@ -0,0 +1,98 @@ +"""UI components for displaying tweets.""" + +import reflex as rx +from ..state.home import HomeState + +def tweet_item(tweet) -> rx.Component: + """Display a single tweet with edit/delete options.""" + return rx.box( + rx.cond( + HomeState.editing_tweet_id == tweet.id, + # Edit mode + rx.vstack( + rx.hstack( + rx.avatar(name=tweet.author, size="3", color="#1DA1F2"), + rx.text(f"@{tweet.author}", font_weight="bold", color="#14171A"), + rx.spacer(), + width="100%", + ), + rx.text_area( + value=HomeState.edit_tweet_content, + on_change=HomeState.set_edit_tweet_content, + width="100%", + min_height="80px", + border_color="#E1E8ED", + _focus={"border_color": "#1DA1F2"}, + ), + rx.hstack( + rx.button( + "Save", + on_click=lambda: HomeState.save_edit_tweet(tweet.id), + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + size="2", + ), + rx.button( + "Cancel", + on_click=HomeState.cancel_edit_tweet, + color="#14171A", + bg="white", + border="1px solid #E1E8ED", + _hover={"bg": "#F7F9FA"}, + border_radius="20px", + size="2", + ), + spacing="2", + ), + spacing="3", + width="100%", + ), + # View mode + rx.vstack( + rx.hstack( + rx.avatar(name=tweet.author, size="3", color="#1DA1F2"), + rx.vstack( + rx.text(f"@{tweet.author}", font_weight="bold", color="#14171A", size="3"), + rx.text(tweet.created_at, color="#657786", font_size="14px"), + spacing="0", + align_items="flex-start", + ), + rx.spacer(), + rx.cond( + tweet.author == HomeState.user.username, + rx.hstack( + rx.icon_button( + rx.icon("pencil", size=16), + on_click=lambda: HomeState.start_edit_tweet(tweet.id, tweet.content), + color="#657786", + variant="ghost", + _hover={"color": "#1DA1F2", "bg": "#F7F9FA"}, + size="2", + ), + rx.icon_button( + rx.icon("trash-2", size=16), + on_click=lambda: HomeState.delete_tweet(tweet.id), + color="#657786", + variant="ghost", + _hover={"color": "#E0245E", "bg": "#F7F9FA"}, + size="2", + ), + spacing="1", + ), + ), + width="100%", + align_items="flex-start", + ), + rx.text(tweet.content, color="#14171A", font_size="15px", width="100%"), + spacing="2", + width="100%", + align_items="flex-start", + ), + ), + padding="16px", + border_bottom="1px solid #E1E8ED", + _hover={"bg": "#F7F9FA"}, + width="100%", + ) \ No newline at end of file diff --git a/twitter/twitter/db_model.py b/twitter/twitter/db_model.py index f3325597..8b32dde0 100644 --- a/twitter/twitter/db_model.py +++ b/twitter/twitter/db_model.py @@ -18,6 +18,11 @@ class User(rx.Model, table=True): username: str password: str + profile_photo: str = "" # URL or path to profile photo + bio: str = "" # User bio/description + display_name: str = "" # Display name (different from username) + location: str = "" # User location + website: str = "" # User website class Tweet(rx.Model, table=True): @@ -27,3 +32,10 @@ class Tweet(rx.Model, table=True): created_at: str author: str + + +class Like(rx.Model, table=True): + """A table of Likes. Tracks which users liked which tweets.""" + + tweet_id: int = Field(primary_key=True) + username: str = Field(primary_key=True) diff --git a/twitter/twitter/layouts/auth.py b/twitter/twitter/layouts/auth.py index 90f55d24..54683f7d 100644 --- a/twitter/twitter/layouts/auth.py +++ b/twitter/twitter/layouts/auth.py @@ -10,33 +10,47 @@ def auth_layout(*args): return rx.box( container( rx.vstack( - rx.heading("Welcome to PySocial!", size="8"), - rx.heading("Sign in or sign up to get started.", size="8"), + rx.heading( + "Welcome to PySocial!", + size="8", + color="#16B8F3", + font_weight="bold", + ), + rx.heading( + "Sign in or sign up to get started.", + size="6", + color="#14171A", + ), align="center", + spacing="2", ), rx.text( "See the source code for this demo app ", rx.link( "here", href="https://github.com/reflex-dev/reflex-examples/tree/main/twitter", + color="#16B8F3", + _hover={"color": "#1A91DA"}, ), ".", - color="gray", + color="#657786", font_weight="medium", ), *args, - border_top_radius="10px", - box_shadow="0 4px 60px 0 rgba(0, 0, 0, 0.08), 0 4px 16px 0 rgba(0, 0, 0, 0.08)", + border_radius="16px", + box_shadow="0 100px 60px 0 rgba(29, 161, 242, 0.1), 0 4px 16px 0 rgba(0, 0, 0, 0.12)", display="flex", flex_direction="column", align_items="center", - padding_top="36px", - padding_bottom="24px", - spacing="4", + padding_top="48px", + padding_bottom="32px", + spacing="5", + background="white", + border="1px solid #E1E8ED", ), height="100vh", padding_top="40px", - background="url(bg.svg)", + background="linear-gradient(135deg, ##16B8F3 0%, #14171A 100%)", background_repeat="no-repeat", background_size="cover", ) diff --git a/twitter/twitter/pages/home.py b/twitter/twitter/pages/home.py index 595fb24b..a14bc410 100644 --- a/twitter/twitter/pages/home.py +++ b/twitter/twitter/pages/home.py @@ -5,7 +5,7 @@ from twitter.state.home import HomeState from ..components import container - +from ..components.tweet import tweet_item def avatar(name: str): return rx.avatar(fallback=name[:2], size="4") @@ -133,7 +133,8 @@ def composer(HomeState): placeholder="What's happening?", resize="none", _focus={"border": 0, "outline": 0, "boxShadow": "none"}, - on_blur=HomeState.set_tweet, + value=HomeState.tweet, + on_change=HomeState.set_tweet, ), padding_y="1.5rem", padding_right="1.5rem", @@ -144,6 +145,8 @@ def composer(HomeState): on_click=HomeState.post_tweet, radius="full", size="3", + color_scheme="blue", + disabled=HomeState.tweet == "", ), justify_content="flex-end", border_top="1px solid #ededed", @@ -160,21 +163,88 @@ def composer(HomeState): def tweet(tweet): """Display for an individual tweet in the feed.""" - return rx.grid( - rx.vstack( - avatar(tweet.author), + return rx.cond( + HomeState.editing_tweet_id == tweet.id, + # Edit mode + rx.grid( + rx.vstack( + avatar(tweet.author), + ), + rx.box( + rx.vstack( + rx.text("@" + tweet.author, font_weight="bold"), + rx.text_area( + value=HomeState.edit_tweet_content, + on_change=HomeState.set_edit_tweet_content, + width="100%", + min_height="80px", + ), + rx.hstack( + rx.button( + "Save", + on_click=lambda: HomeState.save_edit_tweet(tweet.id), + color_scheme="blue", + size="2", + ), + rx.button( + "Cancel", + on_click=HomeState.cancel_edit_tweet, + variant="outline", + size="2", + ), + spacing="2", + ), + align_items="left", + spacing="2", + ), + ), + grid_template_columns="1fr 5fr", + padding="1.5rem", + spacing="1", + border_bottom="1px solid #ededed", ), - rx.box( + # View mode + rx.grid( rx.vstack( - rx.text("@" + tweet.author, font_weight="bold"), - rx.text(tweet.content, width="100%"), - align_items="left", + avatar(tweet.author), + ), + rx.box( + rx.vstack( + rx.hstack( + rx.text("@" + tweet.author, font_weight="bold"), + rx.spacer(), + rx.cond( + tweet.author == State.user.username, + rx.hstack( + rx.icon_button( + rx.icon("pencil", size=16), + on_click=lambda: HomeState.start_edit_tweet(tweet.id, tweet.content), + variant="ghost", + size="2", + color_scheme="blue", + ), + rx.icon_button( + rx.icon("trash-2", size=16), + on_click=lambda: HomeState.delete_tweet(tweet.id), + variant="ghost", + size="2", + color_scheme="red", + ), + spacing="1", + ), + ), + width="100%", + ), + rx.text(tweet.content, width="100%"), + align_items="left", + spacing="2", + ), ), + grid_template_columns="1fr 5fr", + padding="1.5rem", + spacing="1", + border_bottom="1px solid #ededed", ), - grid_template_columns="1fr 5fr", - padding="1.5rem", - spacing="1", - border_bottom="1px solid #ededed", ) @@ -219,3 +289,52 @@ def home(): ), max_width="1300px", ) + + +def compose_tweet() -> rx.Component: + """Compose a new tweet box.""" + return rx.box( + rx.vstack( + rx.text_area( + placeholder="What's happening?", + value=HomeState.tweet, + on_change=HomeState.set_tweet, + width="100%", + min_height="120px", + border="none", + _focus={"border": "none", "outline": "none"}, + font_size="20px", + resize="none", + ), + rx.hstack( + rx.spacer(), + rx.button( + "Tweet", + on_click=HomeState.post_tweet, + color="white", + bg="#1DA1F2", + _hover={"bg": "#1A91DA"}, + border_radius="20px", + font_weight="bold", + padding="10px 24px", + disabled=HomeState.tweet == "", + ), + width="100%", + justify="flex-end", + ), + spacing="3", + width="100%", + ), + padding="16px", + border_bottom="8px solid #E1E8ED", + background="white", + width="100%", + ) + +def tweets_feed() -> rx.Component: + """Display the feed of tweets.""" + return rx.box( + rx.foreach(HomeState.tweets, tweet_item), + width="100%", + background="white", + ) diff --git a/twitter/twitter/state/home.py b/twitter/twitter/state/home.py index 0109c3d1..cc3fc534 100644 --- a/twitter/twitter/state/home.py +++ b/twitter/twitter/state/home.py @@ -12,9 +12,13 @@ class HomeState(State): """The state for the home page.""" - tweet: str + tweet: str = "" tweets: list[Tweet] = [] + # Edit tweet state + editing_tweet_id: int = -1 + edit_tweet_content: str = "" + friend: str search: str @@ -30,6 +34,42 @@ def post_tweet(self): ) session.add(tweet) session.commit() + self.tweet = "" # Clear the tweet input after posting + return self.get_tweets() + + def delete_tweet(self, tweet_id: int): + """Delete a tweet.""" + if not self.logged_in: + return + with rx.session() as session: + tweet = session.exec(select(Tweet).where(Tweet.id == tweet_id)).first() + if tweet and tweet.author == self.user.username: + session.delete(tweet) + session.commit() + return self.get_tweets() + + def start_edit_tweet(self, tweet_id: int, content: str): + """Start editing a tweet.""" + self.editing_tweet_id = tweet_id + self.edit_tweet_content = content + + def cancel_edit_tweet(self): + """Cancel editing a tweet.""" + self.editing_tweet_id = -1 + self.edit_tweet_content = "" + + def save_edit_tweet(self, tweet_id: int): + """Save edited tweet.""" + if not self.logged_in or not self.edit_tweet_content.strip(): + return + with rx.session() as session: + tweet = session.exec(select(Tweet).where(Tweet.id == tweet_id)).first() + if tweet and tweet.author == self.user.username: + tweet.content = self.edit_tweet_content + session.add(tweet) + session.commit() + self.editing_tweet_id = -1 + self.edit_tweet_content = "" return self.get_tweets() def get_tweets(self): diff --git a/twitter/twitter/state/profile.py b/twitter/twitter/state/profile.py new file mode 100644 index 00000000..1f13d458 --- /dev/null +++ b/twitter/twitter/state/profile.py @@ -0,0 +1,87 @@ +"""Profile editing state.""" + +import reflex as rx +from sqlmodel import select + +from ..db_model import User +from .base import State + + +class ProfileState(State): + """State for profile editing.""" + + # Form fields + display_name: str = "" + bio: str = "" + location: str = "" + website: str = "" + profile_photo: str = "" + + # UI state + is_editing: bool = False + upload_progress: int = 0 + + def load_profile_data(self): + """Load the current user's profile data.""" + if self.logged_in: + with rx.session() as session: + user = session.exec( + select(User).where(User.username == self.username) + ).first() + if user: + self.display_name = user.display_name + self.bio = user.bio + self.location = user.location + self.website = user.website + self.profile_photo = user.profile_photo + + def toggle_edit_mode(self): + """Toggle edit mode for profile.""" + if not self.is_editing: + self.load_profile_data() + self.is_editing = not self.is_editing + + def update_profile(self): + """Update the user's profile information.""" + if not self.logged_in: + return + + with rx.session() as session: + user = session.exec( + select(User).where(User.username == self.username) + ).first() + if user: + user.display_name = self.display_name + user.bio = self.bio + user.location = self.location + user.website = self.website + if self.profile_photo: + user.profile_photo = self.profile_photo + session.add(user) + session.commit() + + self.is_editing = False + return rx.toast.success("Profile updated successfully!") + + async def handle_upload(self, files: list[rx.UploadFile]): + """Handle profile photo upload.""" + for file in files: + upload_data = await file.read() + outfile = f"./uploaded_files/{file.filename}" + + # Save the file + with open(outfile, "wb") as file_object: + file_object.write(upload_data) + + # Set the profile photo path + self.profile_photo = f"/uploaded_files/{file.filename}" + self.upload_progress = 100 + + def cancel_edit(self): + """Cancel editing and reset form.""" + self.is_editing = False + self.display_name = "" + self.bio = "" + self.location = "" + self.website = "" + self.upload_progress = 0