Compare commits

..

11 Commits

Author SHA1 Message Date
muszyn 7ff55c4679 fix: fetch was not using rest requests 2025-07-04 12:15:32 -04:00
muszyn a41d1ceaea feat: public self-hosting 2025-07-04 10:04:28 -04:00
muszyn 60c9cec7aa feat: fix vite url using env var 2025-06-25 20:56:16 -04:00
muszyn c0eae42daf feat: expand cors 2025-06-25 19:57:00 -04:00
muszyn afe6c08101 feat(aboutme): update photos and blurb 2025-06-24 21:54:59 -04:00
muszyn 6bda5876ba feat: show author name instead of user x 2025-06-24 21:14:35 -04:00
muszyn 7242579c17 feat: ability to update blog 2025-06-24 20:38:24 -04:00
muszyn 26bb7de018 fix: had to drop db 2025-06-24 20:33:29 -04:00
muszyn 7da38ddd8c feat: ability to add and view blogs for a user 2025-06-24 18:54:48 -04:00
muszyn 07c0977aa7 chore: doc updates 2025-06-14 11:32:56 -04:00
muszyn 2c73c3ba4c feat: about me page 2025-06-14 11:23:22 -04:00
40 changed files with 1776 additions and 332 deletions
+3 -1
View File
@@ -1,7 +1,7 @@
/backups
# Python
/__pycache__/*
__pycache__
# Logs
logs
*.log
@@ -26,3 +26,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env
+9
View File
@@ -31,5 +31,14 @@ Has an automatic backup on a CRON that outputs to the root volume.
### How to build?
The whole thing should run by using docker.
```bash
docker compose up
```
There is a docker-compose file in the root, I would suggest changing the user/password to your db if you ever intend for this to leave your home network.
I'm sure there are a lot of other security measures as well, but at the time of writing this I'm not particular about the security for the web application 2 people might view.
### I have a bug
If you run into any issues feel free to email me at `alexander.muszynski@gmail.com` with the subject being `BlogSiteGitea: {myissue}` or make an issue on the gitea repo. I may or may not get around to it, I just work on this as I feel like it.
+8
View File
@@ -1 +1,9 @@
# Server
The server is built using FastAPI on port `8000`.
If you are running locally you can view all the apis by navigating to `localhost:8000/docs`
This project uses `sqlalchemy` and `alembic` to access and manage the database which is a `postgresql` instance that is also created during the docker compose step.
I would suggest using docker compose for all testing as that is what I will do, running the server in isolation may have bugs so be cautious.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
-25
View File
@@ -1,25 +0,0 @@
# Alembic
Alembic allows for our database migrations to be tracked in a version control system.
To create a new migration run:
```bash
alembic revision --autogenerate -m 'Describe change here'
```
It's best practice to review the script post revision creation: `alembic/versions`
To apply the migration:
```bash
alembic upgrade head
```
Now you can re-check using `alembic check`
If we need to rollback use:
```bash
alembic downgrade -i
```
+4 -1
View File
@@ -1,10 +1,12 @@
from logging.config import fileConfig
from app.database import Base
from sqlalchemy import engine_from_config
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
@@ -16,6 +18,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 = Base.metadata
# other values from the config, defined by the needs of env.py,
@@ -1,41 +0,0 @@
"""init
Revision ID: a3ac646e53a8
Revises:
Create Date: 2025-06-04 21:36:22.283823
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3ac646e53a8'
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_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.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)
# ### end Alembic commands ###
@@ -1,58 +0,0 @@
"""1.1.0
Revision ID: b9dcd098debd
Revises: a3ac646e53a8
Create Date: 2025-06-14 09:22:14.878105
"""
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 = 'b9dcd098debd'
down_revision: Union[str, None] = 'a3ac646e53a8'
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 ###
@@ -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 ###
+60 -11
View File
@@ -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()
+65 -23
View File
@@ -1,7 +1,9 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
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
@@ -9,11 +11,22 @@ from .database import SessionLocal, engine, Base
Base.metadata.create_all(bind=engine)
app = FastAPI()
app = FastAPI(proxy_headers=True)
app.add_middleware(
TrustedHostMiddleware, allowed_hosts=["site-api.muszyn.dev", "*.muszyn.dev"]
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://localhost:3000"],
allow_origins=[
"http://localhost:3000",
"http://localhost:8000",
"http://192.168.125.129:3000",
"https://192.168.125.129:3000",
"http://192.168.125.129:8000",
"https://192.168.125.129:8000",
"https://site.muszyn.dev",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -34,35 +47,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 +117,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)
+31 -5
View File
@@ -1,14 +1,40 @@
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)
author_name = Column(String, 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):
+50 -8
View File
@@ -1,17 +1,58 @@
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
author_name: str
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
author_name: 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):
"""Whats returned in responses."""
id: int
view_count: int
like_count: int
class Config:
from_attributes = True
@@ -41,6 +82,7 @@ class UserOut(UserBase):
class Token(BaseModel):
access_token: str
token_type: str
user_id: int
class TokenData(BaseModel):
+5
View File
@@ -17,3 +17,8 @@ dependencies = [
[tool.pyrefly]
project-includes = ["**/*"]
project-excludes = ["**/*venv/**/*"]
[dependency-groups]
dev = [
"sqlalchemy-stubs>=0.4",
]
+71 -18
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
*/15 * * * * /app/backup.sh
* */1 * * * /app/backup.sh
+5 -3
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
db:
image: postgres:15
@@ -37,7 +35,11 @@ services:
- app-network
frontend:
build: ./frontend
build:
context: ./frontend
args:
VITE_API_URL: ${API_URL}
ports:
- "3000:80"
depends_on:
+13 -10
View File
@@ -1,19 +1,22 @@
# Build stage
FROM node:20 AS build
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY package*.json ./
RUN npm install
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# Production stage
# Stage 2: Serve with NGINX
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Clean out default config
RUN rm /etc/nginx/conf.d/default.conf
# Copy your custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built files from Vite
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
+636 -3
View File
@@ -8,16 +8,23 @@
"name": "blogs-app",
"version": "0.0.0",
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tailwindcss/vite": "^4.1.7",
"fs": "^0.0.1-security",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"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",
@@ -26,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"
@@ -881,6 +887,53 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.3"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1560,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",
@@ -2305,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",
@@ -2399,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",
@@ -2874,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",
@@ -2901,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",
@@ -2914,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",
@@ -2924,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",
@@ -3076,7 +3284,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -3407,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",
@@ -3424,6 +3645,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3443,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",
@@ -3467,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",
@@ -3675,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",
@@ -4159,6 +4652,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4247,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",
@@ -4314,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",
@@ -4331,6 +4859,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -4409,6 +4948,21 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -4484,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",
@@ -4517,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",
@@ -5030,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",
@@ -5044,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",
@@ -5158,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",
+8 -2
View File
@@ -10,16 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tailwindcss/vite": "^4.1.7",
"fs": "^0.0.1-security",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"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",
@@ -28,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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

-42
View File
@@ -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;
}
+331 -52
View File
@@ -1,3 +1,13 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
} from "react";
import cruiseImg from "./images/cruise.jpg";
import discGolfImg from "./images/disc_golf_ace.jpg";
import odinImg from "./images/odin.jpg";
import {
BrowserRouter as Router,
Routes,
@@ -5,21 +15,28 @@ import {
Link,
useNavigate,
} from "react-router-dom";
import { useState, useContext, useEffect, createContext, useRef } from "react";
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 { EditBlog } from "./utils/EditBlog";
import Unauthorized from "./utils/UnauthorizedPage";
// Use Vite environment variable for API base URL
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
import {
FaGithub,
FaGitAlt,
FaLinkedin,
FaTwitter,
FaSun,
FaMoon,
} from "react-icons/fa";
import type { Blog } from "./utils/types";
import { countWords } from "./utils/countWords";
import { API_URL } from "./utils/constants";
// Auth Context
interface AuthContextProps {
isAuthenticated: boolean;
login: (token: string) => void;
login: (token: string, user_id: number) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextProps>({
@@ -40,13 +57,15 @@ 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);
};
const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user_id");
setIsAuthenticated(false);
navigate("/signin");
};
@@ -58,36 +77,230 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
);
};
function BlogPage() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [blogs, setBlogs] = useState<Blog[]>([]);
useEffect(() => {
fetch(`${API_URL}/blogs`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data: Blog[]) => setBlogs(data))
.catch((err) => console.error(err));
}, []);
return (
<div>
<div className="flex justify-end mb-4">
<button
onClick={() =>
isAuthenticated ? navigate("/create") : navigate("/signin")
}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Create
</button>
</div>
<div className="space-y-4">
{blogs.map((blog) => (
<Link
key={blog.id}
to={`/blog/${blog.id}`}
className="block p-4 border rounded hover:bg-gray-100 dark:hover:bg-gray-800"
>
<h3 className="text-xl font-semibold">{blog.title}</h3>
<p className="italic text-gray-600 dark:text-gray-400">
{blog.description}
</p>
<p className="text-sm text-gray-500">By {blog.author_name}</p>
</Link>
))}
</div>
</div>
);
}
// Page for creating a blog post
function CreateBlog() {
const [fileName, setFileName] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [authorName, setAuthorName] = useState("");
const [content, setContent] = useState<string>("");
const navigate = useNavigate();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
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);
setAuthorName(authorName);
};
reader.readAsText(file);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const userId = Number(localStorage.getItem("user_id"));
try {
const body = {
title,
author_id: userId, // still from localStorage
author_name: authorName, // from the form field
description: description,
body: content,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
published_at: new Date().toISOString(),
word_count: countWords(content),
};
await fetch(`${API_URL}/blogs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
navigate("/blog");
} catch (err: any) {
alert(err.message);
}
};
return (
<div className="max-w-xl mx-auto py-10 space-y-6">
<h2 className="text-2xl font-bold">Create Post</h2>
<div className="flex space-x-4">
<label className="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600">
Upload File
<input
type="file"
accept=".txt,.md"
onChange={handleFile}
className="hidden"
/>
</label>
</div>
{fileName && (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Title
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="author" className="block text-sm font-medium mb-1">
Author
</label>
<input
id="author"
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium mb-1"
>
Description
</label>
<input
id="description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">
Content
</label>
<textarea
id="content"
rows={10}
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 max-h-96 overflow-y-auto resize-none"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Submit
</button>
</form>
)}
</div>
);
}
function App() {
const [darkMode, setDarkMode] = useState(true);
// Initialize theme from localStorage or default to light
const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem("theme") === "dark";
});
// Sync dark mode state with <html> class and localStorage
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}, [darkMode]);
return (
<Router>
<AuthProvider>
<div className={darkMode ? "dark" : ""}>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<AppBar toggleDarkMode={() => setDarkMode(!darkMode)} />
<div className="p-4">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/blog" element={<BlogList />} />
<Route path="/blog/:slug" element={<BlogViewer />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route
path="/admin"
element={
<RequireAdmin>
<AdminPage />
</RequireAdmin>
}
/>
<Route path="/unauthorized" element={<Unauthorized />} />
<Route path="/register" element={<Register />} />
<Route path="/signin" element={<SignIn />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</div>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
<AppBar
darkMode={darkMode}
toggleDarkMode={() => setDarkMode((prev) => !prev)}
/>
<div className="p-4">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogViewer />} />
<Route path="/blog/:slug/edit" element={<EditBlog />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route
path="/admin"
element={
<RequireAdmin>
<AdminPage />
</RequireAdmin>
}
/>
<Route path="/unauthorized" element={<Unauthorized />} />
<Route path="/register" element={<Register />} />
<Route path="/signin" element={<SignIn />} />
<Route path="/profile" element={<Profile />} />
<Route path="/create" element={<CreateBlog />} />
</Routes>
</div>
</div>
</AuthProvider>
@@ -95,7 +308,13 @@ function App() {
);
}
function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) {
function AppBar({
darkMode,
toggleDarkMode,
}: {
darkMode: boolean;
toggleDarkMode: () => void;
}) {
const { isAuthenticated, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -111,9 +330,7 @@ function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) {
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
return (
@@ -159,7 +376,7 @@ function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) {
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center"
className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"
>
<span className="text-gray-700 dark:text-gray-200">U</span>
</button>
@@ -183,15 +400,16 @@ function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) {
)}
<button
onClick={toggleDarkMode}
className="text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded"
className="text-xl p-1 bg-gray-200 dark:bg-gray-700 rounded-full"
>
Toggle Dark Mode
{darkMode ? <FaMoon /> : <FaSun />}
</button>
</div>
</nav>
);
}
// Pages and forms
function LandingPage() {
return (
<div className="text-center py-10">
@@ -203,20 +421,71 @@ function LandingPage() {
);
}
// Profile placeholder
function Profile() {
return (
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">Profile</h2>
<p>Profile page coming soon.</p>
</div>
);
}
function About() {
return (
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">About Me</h2>
<p>About page content coming soon.</p>
<div className="py-10">
<div className="flex justify-center items-end space-x-4 flex-wrap">
{[cruiseImg, discGolfImg, odinImg].map((src, i) => (
<img
key={i}
src={src}
alt={["Cruise", "Disc golf ace", "Odin"][i]}
className="
rounded-full
aspect-square
object-cover
w-1/3 max-w-[120px] /* small screens: 33% of container, up to 120px */
sm:w-1/4 sm:max-w-[150px] /* ≥640px: 25% up to 150px */
md:w-1/6 md:max-w-[200px] /* ≥768px: ~16% up to 200px */
lg:w-1/8 lg:max-w-[250px] /* ≥1024px: 12.5% up to 250px */
"
/>
))}
</div>
<div className="text-center mt-8 px-4">
<h2 className="text-3xl font-bold mb-4">About Me</h2>
<p className="text-lg text-gray-700 dark:text-gray-300 max-w-2xl mx-auto">
Im Alex, a full-stack engineer at Whisker, working on backend one
minute, mobile tweaks the next, and firmware the day after. Learning
new tech is something I'm passionate about, and Im all about
spreading that knowledge fast. Off-duty youll catch me reading a
bunch, writing (ironically, on my blog, which you are currently
viewing), exploring the outdoors with my dogs (and my girlfriend,
trust me shes real!), or rolling dice as a D&amp;D sorcerer. Here
youll find book thoughts, code experiments, and random shower
thoughts. Thanks for stopping by!
</p>
</div>
<div className="mt-8 flex flex-col items-center space-y-2">
<a
href="https://github.com/amuszyn"
className="flex items-center space-x-2 text-blue-600 hover:underline"
>
<FaGithub />
<span>github.com/amuszyn</span>
</a>
<a
href="https://gitea.muszyn.dev/"
className="flex items-center space-x-2 text-blue-600 hover:underline"
>
<FaGitAlt />
<span>gitea.muszyn.dev/</span>
</a>
<a
href="https://www.linkedin.com/in/almuszynski/"
className="flex items-center space-x-2 text-blue-600 hover:underline"
>
<FaLinkedin />
<span>linkedin.com/in/almuszynski</span>
</a>
<a
href="https://x.com/Muszynlol"
className="flex items-center space-x-2 text-blue-600 hover:underline"
>
<FaTwitter />
<span>x.com/Muszynlol</span>
</a>
</div>
</div>
);
}
@@ -230,6 +499,15 @@ function Contact() {
);
}
function Profile() {
return (
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">Profile</h2>
<p>Profile page coming soon.</p>
</div>
);
}
// Registration form with API integration
interface RegisterForm {
username: string;
@@ -393,7 +671,8 @@ function SignIn() {
}
const tokenData = await response.json();
login(tokenData.access_token);
alert(response);
login(tokenData.access_token, tokenData.user_id);
navigate("/");
} catch (err: any) {
alert(err.message);
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

+4 -9
View File
@@ -1,10 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
createRoot(document.getElementById("root")!).render(<App />);
+169
View File
@@ -0,0 +1,169 @@
/* markdown.css - Custom wrapper for Markdown content */
.markdown-body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.7;
color: #1e293b;
/* slate-800 */
background-color: #ffffff;
/* white */
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
/* Dark mode override */
@media (prefers-color-scheme: dark) {
.markdown-body {
color: #cbd5e1;
/* slate-300 */
background-color: #0f172a;
/* slate-900 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
}
/* Headings */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
color: inherit;
}
.markdown-body h1 {
font-size: 2.25rem;
}
.markdown-body h2 {
font-size: 1.875rem;
}
.markdown-body h3 {
font-size: 1.5rem;
}
.markdown-body h4 {
font-size: 1.25rem;
}
/* Paragraphs */
.markdown-body p {
margin: 1em 0;
}
/* Links */
.markdown-body a {
color: #3b82f6;
/* blue-500 */
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.markdown-body a {
color: #60a5fa;
/* blue-400 */
}
}
/* Lists */
.markdown-body ul,
.markdown-body ol {
margin: 1em 0 1em 1.5em;
}
/* Blockquotes */
.markdown-body blockquote {
margin: 1em 0;
padding: 0.75em 1em;
border-left: 4px solid #e2e8f0;
/* slate-200 */
background-color: #f1f5f9;
/* slate-100 */
color: #475569;
/* slate-600 */
}
@media (prefers-color-scheme: dark) {
.markdown-body blockquote {
border-color: #334155;
/* slate-700 */
background-color: #1e293b;
/* slate-800 */
color: #cbd5e1;
/* slate-300 */
}
}
/* Inline code */
.markdown-body code {
background-color: #f5f5f5;
color: #db2777;
/* rose-600 */
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.95em;
}
@media (prefers-color-scheme: dark) {
.markdown-body code {
background-color: #1e293b;
/* slate-800 */
color: #f472b6;
/* pink-400 */
}
}
/* Code blocks */
.markdown-body pre {
background-color: #0f172a;
/* slate-900 */
color: #e2e8f0;
/* slate-200 */
padding: 1em;
overflow: auto;
border-radius: 0.5rem;
}
/* Tables */
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #e2e8f0;
/* slate-200 */
padding: 0.75em 1em;
}
.markdown-body th {
background-color: #f1f5f9;
/* slate-100 */
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.markdown-body th,
.markdown-body td {
border-color: #334155;
/* slate-700 */
}
.markdown-body th {
background-color: #334155;
/* slate-700 */
}
}
+7 -1
View File
@@ -1,9 +1,15 @@
import { useState, useEffect } from "react";
import { API_URL } from "./constants";
export function BlogList() {
const [content, setContent] = useState("");
useEffect(() => {
fetch(`localhost:8000/get-blogs`)
fetch(`${API_URL}/get-blogs`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.text())
.then(setContent);
}, []);
View File
+73 -8
View File
@@ -1,15 +1,80 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { API_URL } from "./constants";
import type { Blog } from "./types";
import Markdown from "react-markdown";
import { useParams } from "react-router-dom";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import "../styles/markdown.css";
export function BlogViewer() {
const [content, setContent] = useState("");
const { slug } = useParams();
const { slug } = useParams<{ slug: string }>();
const [blog, setBlog] = useState<Blog | null>(null);
const navigate = useNavigate();
const me = localStorage.getItem("user_id");
useEffect(() => {
fetch(`localhost:8000/get-blogs/${slug}`)
.then((res) => res.text())
.then(setContent);
}, []);
return <Markdown>{content}</Markdown>;
if (!slug) return;
fetch(`${API_URL}/blogs/${slug}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data: Blog) => setBlog(data))
.catch((err) => console.error(err));
}, [slug]);
if (!blog) return <p>Loading</p>;
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 (
<div className="max-w-2xl mx-auto py-10 space-y-6">
{isAuthor && (
<div className="flex space-x-4">
<button
onClick={() => navigate(`/blog/${slug}/edit`)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Edit
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
)}
<h1 className="text-3xl font-bold">{blog.title.toUpperCase()}</h1>
<p className="text-sm text-gray-500">By {blog.author_name}</p>
<div className="markdown-body mx-auto p-4">
<Markdown
remarkPlugins={[remarkGfm, remarkRehype]}
rehypePlugins={[rehypeRaw]}
children={blog.body}
></Markdown>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState, type FormEvent } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { API_URL } from "./constants";
import type { Blog } from "./types";
import { countWords } from "./countWords";
export function EditBlog() {
const { slug } = useParams<{ slug: string }>();
const [blog, setBlog] = useState<Blog | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [body, setBody] = useState("");
const [authorName, setAuthorName] = useState("");
const navigate = useNavigate();
useEffect(() => {
if (!slug) return;
fetch(`${API_URL}/blogs/${slug}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data: Blog) => {
setBlog(data);
setTitle(data.title);
setDescription(data.description);
setBody(data.body);
setAuthorName(data.author_name);
})
.catch(console.error);
}, [slug]);
if (!blog) return <p>Loading</p>;
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 : 1,
};
const res = await fetch(`${API_URL}/blogs/${slug}`, {
method: "PUT", // or "PATCH" if your API prefers
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedBlog),
});
if (res.ok) {
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.");
}
}
return (
<div className="max-w-2xl mx-auto py-10">
<h2 className="text-2xl font-bold mb-4">Edit Blog Post</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-medium">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border px-2 py-1 rounded"
/>
</div>
<div>
<label className="block font-medium">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full border px-2 py-1 rounded"
/>
</div>
<div>
<label className="block font-medium">Author Name</label>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
className="w-full border px-2 py-1 rounded"
required
/>
</div>
<div>
<label className="block font-medium">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
className="w-full border px-2 py-1 rounded font-mono"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Save Changes
</button>
</form>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
+9
View File
@@ -0,0 +1,9 @@
export function countWords(text: string): number {
// Trim leading/trailing whitespace, then split on one-or-more whitespace characters
const words = text.trim().split(/\s+/);
// If the string was empty or only whitespace, split() returns [''], so handle that
if (words.length === 1 && words[0] === "") {
return 0;
}
return words.length;
}
+16
View File
@@ -0,0 +1,16 @@
export interface Blog {
id: number;
title: string;
description: string;
body: string;
author_id: number;
author_name: string;
created_at: string;
updated_at: string;
published_at: string;
word_count: number;
version: number;
read_time: number;
language: string;
tags: string[];
}
+3 -10
View File
@@ -1,16 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}"
],
darkMode: 'class',
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/vite")],
};