diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 36112a3..157c456 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -5,6 +5,8 @@ from sqlalchemy import pool from alembic import context +from app.database import Base + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -17,8 +19,7 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = None +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -64,9 +65,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/backend/alembic/versions/c9b28e38d00c_add_author_name_to_blogs.py b/backend/alembic/versions/c9b28e38d00c_add_author_name_to_blogs.py new file mode 100644 index 0000000..5c29427 --- /dev/null +++ b/backend/alembic/versions/c9b28e38d00c_add_author_name_to_blogs.py @@ -0,0 +1,71 @@ +"""Add author_name to blogs + +Revision ID: c9b28e38d00c +Revises: +Create Date: 2025-06-24 20:51:56.034469 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'c9b28e38d00c' +down_revision: Union[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.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_blogs_author_id'), table_name='blogs') + op.drop_index(op.f('ix_blogs_id'), table_name='blogs') + op.drop_index(op.f('ix_blogs_title'), table_name='blogs') + op.drop_table('blogs') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('blogs', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('title', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('description', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('body', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('published_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('word_count', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('read_time', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('language', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('tags', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.Column('view_count', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('like_count', sa.INTEGER(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('blogs_pkey')) + ) + op.create_index(op.f('ix_blogs_title'), 'blogs', ['title'], unique=False) + op.create_index(op.f('ix_blogs_id'), 'blogs', ['id'], unique=False) + op.create_index(op.f('ix_blogs_author_id'), 'blogs', ['author_id'], unique=False) + op.create_table('users', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('permissions', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False), + sa.Column('subscriber', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')) + ) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + # ### end Alembic commands ### diff --git a/backend/app/models.py b/backend/app/models.py index d31ad7d..8ae55b0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ class Blog(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) author_id = Column(Integer, nullable=False, index=True) + author_name = Column(String, nullable=False, index=True) description = Column(String, nullable=True) body = Column(String, nullable=False) created_at = Column( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0bcbb8f..4ae8c38 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -5,6 +5,7 @@ from pydantic import AwareDatetime, BaseModel, EmailStr class BlogBase(BaseModel): title: str author_id: int + author_name: str description: Optional[str] = None body: str created_at: AwareDatetime @@ -30,6 +31,7 @@ class BlogUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None + author_name: Optional[str] = None body: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb11991..10af657 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ const logout = () => { localStorage.removeItem("token"); + localStorage.removeItem("user_id"); setIsAuthenticated(false); navigate("/signin"); }; @@ -107,7 +108,10 @@ function BlogPage() { className="block p-4 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" >

{blog.title}

-

By {blog.author_id}

+

+ {blog.description} +

+

By {blog.author_name}

))} @@ -118,10 +122,9 @@ function BlogPage() { // Page for creating a blog post function CreateBlog() { const [fileName, setFileName] = useState(""); - const [title, setTitle] = useState(""); - const username = localStorage.getItem("user_id") || ""; - const [author, setAuthor] = useState(username); - const [description, setDescription] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [authorName, setAuthorName] = useState(""); const [content, setContent] = useState(""); const navigate = useNavigate(); @@ -135,33 +138,31 @@ function CreateBlog() { setFileName(file.name); const baseName = file.name.replace(/\.[^.]+$/, ""); setTitle(baseName); - setAuthor(username); + setAuthorName(authorName); }; reader.readAsText(file); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + const userId = Number(localStorage.getItem("user_id")); try { const body = { - title: title, - author_id: localStorage.getItem("user_id"), + title, + author_id: userId, // still from localStorage + author_name: authorName, // from the form field description: description, body: content, - created_at: new Date(Date.now()).toISOString(), - updated_at: new Date(Date.now()).toISOString(), - published_at: new Date(Date.now()).toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + published_at: new Date().toISOString(), word_count: countWords(content), - version: 1, - read_time: 0, - language: "US", }; - const res = await fetch(`${API_URL}/blogs`, { + await fetch(`${API_URL}/blogs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - alert(res); navigate("/blog"); } catch (err: any) { alert(err.message); @@ -203,8 +204,8 @@ function CreateBlog() { setAuthor(e.target.value)} + value={authorName} + onChange={(e) => setAuthorName(e.target.value)} className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2" /> diff --git a/frontend/src/utils/BlogViewer.tsx b/frontend/src/utils/BlogViewer.tsx index 212972c..e9537be 100644 --- a/frontend/src/utils/BlogViewer.tsx +++ b/frontend/src/utils/BlogViewer.tsx @@ -26,21 +26,43 @@ export function BlogViewer() { const isAuthor = me === String(blog.author_id); + const handleDelete = async () => { + if (!window.confirm(`Are you sure you want to delete ${blog.title}`)) + return; + + try { + const res = await fetch(`${API_URL}/blogs/${slug}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(await res.text()); + navigate("/"); + } catch (err) { + console.error("Delete failed:", err); + alert("Could not delete post. See console for details."); + } + }; + return (
{isAuthor && ( - +
+ + +
)}

{blog.title.toUpperCase()}

-

By User {blog.author_id}

-

- {blog.description} -

+

By {blog.author_name}

(); - const [blog, setBlog] = useState(undefined); + const [blog, setBlog] = useState(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [body, setBody] = useState(""); + const [authorName, setAuthorName] = useState(""); const navigate = useNavigate(); useEffect(() => { @@ -22,6 +23,7 @@ export function EditBlog() { setTitle(data.title); setDescription(data.description); setBody(data.body); + setAuthorName(data.author_name); }) .catch(console.error); }, [slug]); @@ -30,14 +32,14 @@ export function EditBlog() { async function handleSubmit(e: FormEvent) { e.preventDefault(); - const updatedBlog = { title, description, body, + author_name: authorName, // from the form field updated_at: new Date().toISOString(), word_count: countWords(body), - version: blog ? blog.version : 1, + version: blog ? blog.version + 1 : 1, }; const res = await fetch(`${API_URL}/blogs/${slug}`, { @@ -45,9 +47,8 @@ export function EditBlog() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedBlog), }); - if (res.ok) { - navigate(`/blog/${slug}`); // go back to the viewer + navigate(`/blogs/${slug}`); // go back to the viewer } else { console.error("Update failed:", await res.text()); alert("Could not update the post. See console for details."); @@ -74,6 +75,16 @@ export function EditBlog() { className="w-full border px-2 py-1 rounded" />
+
+ + setAuthorName(e.target.value)} + className="w-full border px-2 py-1 rounded" + required + /> +