From 7da38ddd8ce7d6ed92959010879ae0cd4c8250d9 Mon Sep 17 00:00:00 2001 From: Alex Muszynski Date: Tue, 24 Jun 2025 18:54:48 -0400 Subject: [PATCH] feat: ability to add and view blogs for a user --- backend/alembic/versions/1331953dbdf0_.py | 46 ++ backend/alembic/versions/273690fd257d_.py | 32 ++ backend/alembic/versions/3b78fe6cf60f_.py | 32 ++ backend/alembic/versions/4a1e42eba1cf_.py | 58 +++ backend/alembic/versions/aaeb70eb4cdb_.py | 32 ++ backend/alembic/versions/d4894b788937_.py | 28 ++ backend/alembic/versions/f6c8aa750e08_.py | 104 +++++ backend/app/crud.py | 71 ++- backend/app/main.py | 72 ++- backend/app/models.py | 35 +- backend/app/schemas.py | 56 ++- backend/pyproject.toml | 5 + backend/uv.lock | 89 +++- backup/crontab.txt | 2 +- frontend/package-lock.json | 541 +++++++++++++++++++++- frontend/package.json | 7 +- frontend/src/App.css | 42 -- frontend/src/App.tsx | 187 +++++++- frontend/src/main.tsx | 13 +- frontend/src/styles/markdown.css | 169 +++++++ frontend/src/utils/BlogPage.tsx | 0 frontend/src/utils/BlogViewer.tsx | 44 +- frontend/src/utils/constants.ts | 1 + frontend/src/utils/countWords.ts | 9 + frontend/src/utils/types.ts | 7 + frontend/tailwind.config.js | 13 +- 26 files changed, 1553 insertions(+), 142 deletions(-) create mode 100644 backend/alembic/versions/1331953dbdf0_.py create mode 100644 backend/alembic/versions/273690fd257d_.py create mode 100644 backend/alembic/versions/3b78fe6cf60f_.py create mode 100644 backend/alembic/versions/4a1e42eba1cf_.py create mode 100644 backend/alembic/versions/aaeb70eb4cdb_.py create mode 100644 backend/alembic/versions/d4894b788937_.py create mode 100644 backend/alembic/versions/f6c8aa750e08_.py delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/styles/markdown.css create mode 100644 frontend/src/utils/BlogPage.tsx create mode 100644 frontend/src/utils/constants.ts create mode 100644 frontend/src/utils/countWords.ts create mode 100644 frontend/src/utils/types.ts diff --git a/backend/alembic/versions/1331953dbdf0_.py b/backend/alembic/versions/1331953dbdf0_.py new file mode 100644 index 0000000..54a8001 --- /dev/null +++ b/backend/alembic/versions/1331953dbdf0_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 1331953dbdf0 +Revises: f6c8aa750e08 +Create Date: 2025-06-22 14:23:39.931696 + +""" +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 = '1331953dbdf0' +down_revision: Union[str, None] = 'f6c8aa750e08' +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') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + 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/alembic/versions/273690fd257d_.py b/backend/alembic/versions/273690fd257d_.py new file mode 100644 index 0000000..4363820 --- /dev/null +++ b/backend/alembic/versions/273690fd257d_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 273690fd257d +Revises: aaeb70eb4cdb +Create Date: 2025-06-21 09:08:04.102896 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '273690fd257d' +down_revision: Union[str, None] = 'aaeb70eb4cdb' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3b78fe6cf60f_.py b/backend/alembic/versions/3b78fe6cf60f_.py new file mode 100644 index 0000000..9ea0420 --- /dev/null +++ b/backend/alembic/versions/3b78fe6cf60f_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 3b78fe6cf60f +Revises: 1331953dbdf0 +Create Date: 2025-06-22 14:24:52.883175 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3b78fe6cf60f' +down_revision: Union[str, None] = '1331953dbdf0' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/4a1e42eba1cf_.py b/backend/alembic/versions/4a1e42eba1cf_.py new file mode 100644 index 0000000..9a3946d --- /dev/null +++ b/backend/alembic/versions/4a1e42eba1cf_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: 4a1e42eba1cf +Revises: b9dcd098debd +Create Date: 2025-06-21 08:55:29.395985 + +""" +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 = '4a1e42eba1cf' +down_revision: Union[str, None] = 'b9dcd098debd' +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_items_id'), table_name='items') + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_table('items') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('items', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('description', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('body', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('items_pkey')) + ) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + op.create_index(op.f('ix_items_id'), 'items', ['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/alembic/versions/aaeb70eb4cdb_.py b/backend/alembic/versions/aaeb70eb4cdb_.py new file mode 100644 index 0000000..b210a5c --- /dev/null +++ b/backend/alembic/versions/aaeb70eb4cdb_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: aaeb70eb4cdb +Revises: d4894b788937 +Create Date: 2025-06-21 09:06:10.983927 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'aaeb70eb4cdb' +down_revision: Union[str, None] = 'd4894b788937' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/d4894b788937_.py b/backend/alembic/versions/d4894b788937_.py new file mode 100644 index 0000000..44a68db --- /dev/null +++ b/backend/alembic/versions/d4894b788937_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: d4894b788937 +Revises: 4a1e42eba1cf +Create Date: 2025-06-21 08:56:04.467650 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4894b788937' +down_revision: Union[str, None] = '4a1e42eba1cf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/backend/alembic/versions/f6c8aa750e08_.py b/backend/alembic/versions/f6c8aa750e08_.py new file mode 100644 index 0000000..7d6388f --- /dev/null +++ b/backend/alembic/versions/f6c8aa750e08_.py @@ -0,0 +1,104 @@ +"""empty message + +Revision ID: f6c8aa750e08 +Revises: 273690fd257d +Create Date: 2025-06-22 14:14:03.560792 + +""" + +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 = "f6c8aa750e08" +down_revision: Union[str, None] = "273690fd257d" +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_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=True), + sa.Column("description", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column( + "body", + postgresql.JSON(astext_type=sa.Text()), + 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.ForeignKeyConstraint( + ["author_id"], ["users.id"], name=op.f("blogs_author_id_fkey") + ), + 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/crud.py b/backend/app/crud.py index 45b8ba8..772ced8 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,26 +1,75 @@ from sqlalchemy.orm import Session from app import models, schemas from app.utils import hash_password, verify_password +from typing import List, Optional -def get_item(db: Session, item_id: int): - return db.query(models.Item).filter(models.Item.id == item_id).first() +def get_blog(db: Session, blog_id: int) -> Optional[models.Blog]: + return db.query(models.Blog).filter(models.Blog.id == blog_id).first() -def get_items(db: Session, skip: int = 0, limit: int = 10): - return db.query(models.Item).offset(skip).limit(limit).all() +def get_blogs( + db: Session, + skip: int = 0, + limit: int = 10, + author_id: Optional[int] = None, + visibility: Optional[str] = None, +) -> List[models.Blog]: + q = db.query(models.Blog) + if author_id is not None: + q = q.filter(models.Blog.author_id == author_id) + if visibility is not None: + q = q.filter(models.Blog.visibility == visibility) + return q.offset(skip).limit(limit).all() -def create_item(db: Session, item: schemas.Item): - db_item = models.Item(**item.model_dump()) - db.add(db_item) +def create_blog(db: Session, blog_in: schemas.BlogCreate) -> models.Blog: + db_blog = models.Blog(**blog_in.model_dump()) + db.add(db_blog) db.commit() - db.refresh(db_item) - return db_item + db.refresh(db_blog) + return db_blog -def delete_item(db: Session, item_id: int): - item = db.query(models.Item).filter(models.Item.id == item_id).first() +def update_blog( + db: Session, blog_id: int, blog_in: schemas.BlogUpdate +) -> Optional[models.Blog]: + db_blog = get_blog(db, blog_id) + if not db_blog: + return None + update_data = blog_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_blog, field, value) + db.commit() + db.refresh(db_blog) + return db_blog + + +def delete_blog(db: Session, blog_id: int) -> Optional[models.Blog]: + db_blog = get_blog(db, blog_id) + if not db_blog: + return None + db.delete(db_blog) + db.commit() + return db_blog + + +def increment_view_count(db: Session, blog_id: int) -> None: + db.query(models.Blog).filter(models.Blog.id == blog_id).update( + {"view_count": models.Blog.view_count + 1} + ) + db.commit() + + +def add_like(db: Session, blog_id: int) -> None: + db.query(models.Blog).filter(models.Blog.id == blog_id).update( + {"like_count": models.Blog.like_count + 1} + ) + db.commit() + + +def delete_blogs(db: Session, item_id: int): + item = db.query(models.Blog).filter(models.Blog.id == item_id).first() if item: db.delete(item) db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index a50819a..9c7ab71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session +from typing import Optional from app.utils import create_access_token from . import schemas, crud @@ -34,35 +35,64 @@ def health_check(): return {"Health": "Super Healthy!"} -@app.post("/items/", response_model=schemas.Item) -def create_item(item: schemas.Item, db: Session = Depends(get_db)): - return crud.create_item(db, item) +@app.post("/blogs/", response_model=schemas.Blog) +def create_blog( + blog: schemas.BlogCreate, + db: Session = Depends(get_db), +): + return crud.create_blog(db, blog) -@app.get("/items/", response_model=list[schemas.Item]) -def read_items(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): - return crud.get_items(db, skip, limit) +@app.get("/blogs/", response_model=list[schemas.Blog]) +def read_blogs( + skip: int = 0, + limit: int = 10, + author_id: Optional[int] = None, + db: Session = Depends(get_db), +): + return crud.get_blogs( + db, + skip=skip, + limit=limit, + author_id=author_id, + ) -@app.get("/items/{item_id}", response_model=schemas.Item) -def read_item(item_id: int, db: Session = Depends(get_db)): - db_item = crud.get_item(db, item_id) - if db_item is None: - raise HTTPException(status_code=404, detail="Item not found") - return db_item +@app.get("/blogs/{blog_id}", response_model=schemas.Blog) +def read_blog( + blog_id: int, + db: Session = Depends(get_db), +): + db_blog = crud.get_blog(db, blog_id) + if not db_blog: + raise HTTPException(status_code=404, detail="Blog not found") + return db_blog -@app.delete("/items/{item_id}", response_model=schemas.Item) -def delete_item(item_id: int, db: Session = Depends(get_db)): - item = crud.delete_item(db, item_id) - if item is None: - raise HTTPException(status_code=404, detail="Item not found") - return item +@app.put("/blogs/{blog_id}", response_model=schemas.Blog) +def update_blog( + blog_id: int, + blog_in: schemas.BlogUpdate, + db: Session = Depends(get_db), +): + updated = crud.update_blog(db, blog_id, blog_in) + if not updated: + raise HTTPException(status_code=404, detail="Blog not found") + return updated + + +@app.delete("/blogs/{blog_id}", response_model=schemas.Blog) +def delete_blog( + blog_id: int, + db: Session = Depends(get_db), +): + deleted = crud.delete_blog(db, blog_id) + if not deleted: + raise HTTPException(status_code=404, detail="Blog not found") + return deleted # Users - - @app.post("/login", response_model=schemas.Token) def user_login( form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) @@ -75,7 +105,7 @@ def user_login( headers={"WWW-Authenticate": "Bearer"}, ) access_token = create_access_token(data={"sub": user.username}) - return {"access_token": access_token, "token_type": "bearer"} + return {"access_token": access_token, "token_type": "bearer", "user_id": user.id} @app.post("/register", response_model=schemas.UserOut) diff --git a/backend/app/models.py b/backend/app/models.py index 66cf1f5..d31ad7d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,14 +1,39 @@ -from sqlalchemy import JSON, Boolean, Column, Integer, String +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + Integer, + String, + func, +) from .database import Base -class Item(Base): - __tablename__ = "items" +class Blog(Base): + __tablename__ = "blogs" id = Column(Integer, primary_key=True, index=True) - name = Column(String, index=True) + title = Column(String, index=True) + author_id = Column(Integer, nullable=False, index=True) description = Column(String, nullable=True) - body = Column(JSON, nullable=False) + body = Column(String, nullable=False) + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + published_at = Column(DateTime(timezone=True), nullable=True) + + # Document Meta Data + word_count = Column(Integer, nullable=True) + version = Column(Integer, nullable=True) + read_time = Column(Integer, nullable=True) + language = Column(String, nullable=True) + tags = Column(JSON, nullable=True) + + # User Meta Data + view_count = Column(Integer, default=0, nullable=False) + like_count = Column(Integer, default=0, nullable=False) class User(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1c0eaa1..0bcbb8f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,17 +1,56 @@ -from pydantic import BaseModel, EmailStr +from typing import Optional +from pydantic import AwareDatetime, BaseModel, EmailStr -# DB Schemas - - -class ItemBase(BaseModel): - name: str - description: str | None = None +class BlogBase(BaseModel): + title: str + author_id: int + description: Optional[str] = None body: str + created_at: AwareDatetime + updated_at: AwareDatetime + published_at: AwareDatetime + + # Document meta + word_count: Optional[int] = None + version: Optional[int] = None + read_time: Optional[int] = None + language: Optional[str] = None + tags: Optional[list[str]] = None -class Item(ItemBase): +class BlogCreate(BlogBase): + """All fields required to create a new blog post.""" + + pass + + +class BlogUpdate(BaseModel): + """All fields are optional for partial updates.""" + + title: Optional[str] = None + description: Optional[str] = None + body: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + published_at: Optional[str] = None + + word_count: Optional[int] = None + version: Optional[int] = None + read_time: Optional[int] = None + language: Optional[str] = None + tags: Optional[list[str]] = None + + view_count: Optional[int] = None + like_count: Optional[int] = None + + +class Blog(BlogBase): + """What’s returned in responses.""" + id: int + view_count: int + like_count: int class Config: from_attributes = True @@ -41,6 +80,7 @@ class UserOut(UserBase): class Token(BaseModel): access_token: str token_type: str + user_id: int class TokenData(BaseModel): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 362f2fa..7ca3711 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,3 +17,8 @@ dependencies = [ [tool.pyrefly] project-includes = ["**/*"] project-excludes = ["**/*venv/**/*"] + +[dependency-groups] +dev = [ + "sqlalchemy-stubs>=0.4", +] diff --git a/backend/uv.lock b/backend/uv.lock index 4431412..0fbec36 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -508,6 +508,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -522,6 +563,15 @@ bcrypt = [ { name = "bcrypt" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.10" @@ -672,22 +722,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] -[[package]] -name = "pyrefly" -version = "0.18.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/25/a01a7c1dbad97d9108f0445a75b971170b5f748781864e01e942b572161d/pyrefly-0.18.1.tar.gz", hash = "sha256:e192edc0a4916b56bc89362d569956a93f6fdd2ed60c63cbc14ea86a8d7c072b", size = 1000813, upload-time = "2025-06-07T00:19:35.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/53/ae13e448e4fab6f6106ab3b690ea36ec6d33a824f10507e5cf63515c4420/pyrefly-0.18.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:311cd1855fb991dd79aee550c26be88457ea2e547bc08568a6481dfcb8f35a4a", size = 5619666, upload-time = "2025-06-07T00:19:19.911Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/8551414e7300d4937afd74af474435d20b6e42d33962700b2c2e02c5cf85/pyrefly-0.18.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3abb545a427f105f5c743a0aa8a4bd59a8481df31c1feb6c886336804ed58395", size = 5210517, upload-time = "2025-06-07T00:19:22.192Z" }, - { url = "https://files.pythonhosted.org/packages/23/52/edf7bda660cf7e6a7a25a75a067d46708ecf61ffa423aeb727476dcc85e8/pyrefly-0.18.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:443b4376a6c6f38cdab521e3268e3844e88c3f465eccfd757387725245640e01", size = 5415912, upload-time = "2025-06-07T00:19:24.024Z" }, - { url = "https://files.pythonhosted.org/packages/1d/4a/1b1c626a4d076c3c63d6f2fe80d6317dee61bf957766353aded6abcfb75e/pyrefly-0.18.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5c2a3d458eae1a293ba1b3acae679ae611fd22ec486e095c02f828f2c60f06", size = 6086858, upload-time = "2025-06-07T00:19:25.944Z" }, - { url = "https://files.pythonhosted.org/packages/0f/35/995862038f8e708cab59f5f45fe830044bd85bcdd2fba330b08934e9bc2f/pyrefly-0.18.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe5801cf26fafe0cfcc99d89a2c7632bf8b1edfa05b142ad1ac18621a042de68", size = 5849624, upload-time = "2025-06-07T00:19:27.988Z" }, - { url = "https://files.pythonhosted.org/packages/15/cb/181aada3e76fc740a6bc628b597c092042b880285c3a2fd2a465e6dfffa5/pyrefly-0.18.1-py3-none-win32.whl", hash = "sha256:31e663ca9208cfb4021026784936cd21c4350235d51bbc3cbad7c0dc97649421", size = 5243708, upload-time = "2025-06-07T00:19:29.932Z" }, - { url = "https://files.pythonhosted.org/packages/d8/3e/d1ca8927cdde89e2f202ac7b734d7cbe6970405e4c34a0329dfead0ef5a8/pyrefly-0.18.1-py3-none-win_amd64.whl", hash = "sha256:bb7da09aadd6a53bb210b8eaa4ce86471fb27222e7d2c785bb32d02254f17a66", size = 5668996, upload-time = "2025-06-07T00:19:31.88Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/1aff0e1d26cee78fdadf86777a1ad5b305a7dec7b61e27b51d84e255f4d8/pyrefly-0.18.1-py3-none-win_arm64.whl", hash = "sha256:88220a9ff947c9f2c9ab81e06a5e334b0104b9987317e961219d218b367d9192", size = 5321319, upload-time = "2025-06-07T00:19:33.657Z" }, -] - [[package]] name = "python-dotenv" version = "1.1.0" @@ -809,12 +843,16 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "passlib", extra = ["bcrypt"] }, { name = "psycopg2-binary" }, - { name = "pyrefly" }, { name = "python-jose", extra = ["cryptography"] }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "sqlalchemy-stubs" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.16.1" }, @@ -822,12 +860,14 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, - { name = "pyrefly", specifier = ">=0.18.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "sqlalchemy", specifier = ">=2.0.41" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.2" }, ] +[package.metadata.requires-dev] +dev = [{ name = "sqlalchemy-stubs", specifier = ">=0.4" }] + [[package]] name = "shellingham" version = "1.5.4" @@ -892,6 +932,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] +[[package]] +name = "sqlalchemy-stubs" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/60/db082788267740b17eac2c00666bbea1c8c5a94b569e8b1ea76b0cf42d57/sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae", size = 70682, upload-time = "2021-01-12T14:02:04.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/ae/cb215ab25b76228bc90c90444b87e323ffba58c212321a53d5bc92903098/sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5", size = 116067, upload-time = "2021-01-12T14:02:02.723Z" }, +] + [[package]] name = "starlette" version = "0.46.2" diff --git a/backup/crontab.txt b/backup/crontab.txt index 445eaab..cbb6415 100644 --- a/backup/crontab.txt +++ b/backup/crontab.txt @@ -1 +1 @@ -*/15 * * * * /app/backup.sh +* */1 * * * /app/backup.sh diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56adbf0..9c283ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,10 +17,14 @@ "react-hook-form": "^7.57.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router-dom": "^7.6.0" + "react-router-dom": "^7.6.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-rehype": "^11.1.2" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@tailwindcss/typography": "^0.5.16", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", @@ -29,7 +33,6 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", - "tailwindcss": "^4.1.7", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5" @@ -1610,6 +1613,22 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.7.tgz", @@ -2355,6 +2374,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2449,6 +2481,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -2924,6 +2968,64 @@ "node": ">=8" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -2951,6 +3053,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -2964,6 +3095,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2974,6 +3122,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3456,6 +3614,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3504,6 +3676,44 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -3528,6 +3738,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -3736,6 +4047,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -4317,6 +4749,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4384,6 +4828,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -4580,6 +5038,39 @@ "react-dom": ">=18" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -4613,6 +5104,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5126,6 +5632,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -5140,6 +5653,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", @@ -5254,6 +5781,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c33f105..6deb4fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,10 +19,14 @@ "react-hook-form": "^7.57.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router-dom": "^7.6.0" + "react-router-dom": "^7.6.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-rehype": "^11.1.2" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@tailwindcss/typography": "^0.5.16", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", @@ -31,7 +35,6 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", - "tailwindcss": "^4.1.7", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5" diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a79eeb8..929020b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,6 @@ import { } from "react-router-dom"; import { useForm } from "react-hook-form"; import { BlogViewer } from "./utils/BlogViewer"; -import { BlogList } from "./utils/BlogList"; import { RequireAdmin } from "./utils/RouteGuard"; import { AdminPage } from "./utils/AdminPage"; import Unauthorized from "./utils/UnauthorizedPage"; @@ -26,6 +25,8 @@ import { FaSun, FaMoon, } from "react-icons/fa"; +import type { Blog } from "./utils/types"; +import { countWords } from "./utils/countWords"; // Base API URL from env const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; @@ -33,7 +34,7 @@ const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; // Auth Context interface AuthContextProps { isAuthenticated: boolean; - login: (token: string) => void; + login: (token: string, user_id: number) => void; logout: () => void; } const AuthContext = createContext({ @@ -54,8 +55,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ setIsAuthenticated(!!token); }, []); - const login = (token: string) => { + const login = (token: string, user_id: number) => { localStorage.setItem("token", token); + localStorage.setItem("user_id", user_id.toString()); setIsAuthenticated(true); }; @@ -72,6 +74,179 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ ); }; +function BlogPage() { + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const [blogs, setBlogs] = useState([]); + + useEffect(() => { + fetch(`${API_URL}/blogs`) + .then((res) => res.json()) + .then((data: Blog[]) => setBlogs(data)) + .catch((err) => console.error(err)); + }, []); + + return ( +
+
+ +
+
+ {blogs.map((blog) => ( + +

{blog.title}

+

By {blog.author_id}

+ + ))} +
+
+ ); +} + +// 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 [content, setContent] = useState(""); + const navigate = useNavigate(); + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + setContent(text); + setFileName(file.name); + const baseName = file.name.replace(/\.[^.]+$/, ""); + setTitle(baseName); + setAuthor(username); + }; + reader.readAsText(file); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + // Stubbed API call + try { + const body = { + title: title, + author_id: localStorage.getItem("user_id"), + 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(), + word_count: countWords(content), + version: 1, + read_time: 0, + language: "US", + }; + const res = 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); + } + }; + + return ( +
+

Create Post

+
+ +
+ {fileName && ( +
+
+ + setTitle(e.target.value)} + className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2" + /> +
+
+ + setAuthor(e.target.value)} + className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2" + /> +
+
+ + setDescription(e.target.value)} + className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2" + /> +
+
+ +