Compare commits
12 Commits
2729ba49f2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ff55c4679 | |||
| a41d1ceaea | |||
| 60c9cec7aa | |||
| c0eae42daf | |||
| afe6c08101 | |||
| 6bda5876ba | |||
| 7242579c17 | |||
| 26bb7de018 | |||
| 7da38ddd8c | |||
| 07c0977aa7 | |||
| 2c73c3ba4c | |||
| 8db73f113c |
+3
-1
@@ -1,7 +1,7 @@
|
||||
/backups
|
||||
|
||||
# Python
|
||||
/__pycache__/*
|
||||
__pycache__
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -26,3 +26,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
"""What’s 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):
|
||||
|
||||
@@ -17,3 +17,8 @@ dependencies = [
|
||||
[tool.pyrefly]
|
||||
project-includes = ["**/*"]
|
||||
project-excludes = ["**/*venv/**/*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"sqlalchemy-stubs>=0.4",
|
||||
]
|
||||
|
||||
Generated
+71
-18
@@ -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
@@ -1 +1 @@
|
||||
*/15 * * * * /app/backup.sh
|
||||
* */1 * * * /app/backup.sh
|
||||
|
||||
+5
-3
@@ -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
@@ -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;"]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+636
-3
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
+312
-33
@@ -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,20 +77,214 @@ 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="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={<BlogList />} />
|
||||
<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
|
||||
@@ -86,16 +299,22 @@ function App() {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/signin" element={<SignIn />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/create" element={<CreateBlog />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
I’m 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 I’m all about
|
||||
spreading that knowledge fast. Off-duty you’ll 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 she’s real!), or rolling dice as a D&D sorcerer. Here
|
||||
you’ll 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 |
@@ -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 />);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user