Compare commits

..

8 Commits

Author SHA1 Message Date
muszyn 7ff55c4679 fix: fetch was not using rest requests 2025-07-04 12:15:32 -04:00
muszyn a41d1ceaea feat: public self-hosting 2025-07-04 10:04:28 -04:00
muszyn 60c9cec7aa feat: fix vite url using env var 2025-06-25 20:56:16 -04:00
muszyn c0eae42daf feat: expand cors 2025-06-25 19:57:00 -04:00
muszyn afe6c08101 feat(aboutme): update photos and blurb 2025-06-24 21:54:59 -04:00
muszyn 6bda5876ba feat: show author name instead of user x 2025-06-24 21:14:35 -04:00
muszyn 7242579c17 feat: ability to update blog 2025-06-24 20:38:24 -04:00
muszyn 26bb7de018 fix: had to drop db 2025-06-24 20:33:29 -04:00
31 changed files with 355 additions and 528 deletions
+2
View File
@@ -26,3 +26,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
-25
View File
@@ -1,25 +0,0 @@
# Alembic
Alembic allows for our database migrations to be tracked in a version control system.
To create a new migration run:
```bash
alembic revision --autogenerate -m 'Describe change here'
```
It's best practice to review the script post revision creation: `alembic/versions`
To apply the migration:
```bash
alembic upgrade head
```
Now you can re-check using `alembic check`
If we need to rollback use:
```bash
alembic downgrade -i
```
+4 -1
View File
@@ -1,10 +1,12 @@
from logging.config import fileConfig from logging.config import fileConfig
from app.database import Base
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from alembic import context from alembic import context
from app.database import Base
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config
@@ -16,6 +18,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
-46
View File
@@ -1,46 +0,0 @@
"""empty message
Revision ID: 1331953dbdf0
Revises: f6c8aa750e08
Create Date: 2025-06-22 14:23:39.931696
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1331953dbdf0'
down_revision: Union[str, None] = 'f6c8aa750e08'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('permissions', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('subscriber', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey'))
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 273690fd257d
Revises: aaeb70eb4cdb
Create Date: 2025-06-21 09:08:04.102896
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '273690fd257d'
down_revision: Union[str, None] = 'aaeb70eb4cdb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 3b78fe6cf60f
Revises: 1331953dbdf0
Create Date: 2025-06-22 14:24:52.883175
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3b78fe6cf60f'
down_revision: Union[str, None] = '1331953dbdf0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-58
View File
@@ -1,58 +0,0 @@
"""empty message
Revision ID: 4a1e42eba1cf
Revises: b9dcd098debd
Create Date: 2025-06-21 08:55:29.395985
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4a1e42eba1cf'
down_revision: Union[str, None] = 'b9dcd098debd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_items_id'), table_name='items')
op.drop_index(op.f('ix_items_name'), table_name='items')
op.drop_table('items')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('description', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('body', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('items_pkey'))
)
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False)
op.create_table('users',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('permissions', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('subscriber', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey'))
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
@@ -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 ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: aaeb70eb4cdb
Revises: d4894b788937
Create Date: 2025-06-21 09:06:10.983927
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'aaeb70eb4cdb'
down_revision: Union[str, None] = 'd4894b788937'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -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 ###
-28
View File
@@ -1,28 +0,0 @@
"""empty message
Revision ID: d4894b788937
Revises: 4a1e42eba1cf
Create Date: 2025-06-21 08:56:04.467650
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4894b788937'
down_revision: Union[str, None] = '4a1e42eba1cf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
-104
View File
@@ -1,104 +0,0 @@
"""empty message
Revision ID: f6c8aa750e08
Revises: 273690fd257d
Create Date: 2025-06-22 14:14:03.560792
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "f6c8aa750e08"
down_revision: Union[str, None] = "273690fd257d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_blogs_author_id"), table_name="blogs")
op.drop_index(op.f("ix_blogs_id"), table_name="blogs")
op.drop_index(op.f("ix_blogs_title"), table_name="blogs")
op.drop_table("blogs")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"blogs",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column("title", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column("author_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("description", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column(
"body",
postgresql.JSON(astext_type=sa.Text()),
autoincrement=False,
nullable=False,
),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.Column(
"updated_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=True,
),
sa.Column(
"published_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=True,
),
sa.Column("word_count", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("version", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("read_time", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("language", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column(
"tags",
postgresql.JSON(astext_type=sa.Text()),
autoincrement=False,
nullable=True,
),
sa.Column("view_count", sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column("like_count", sa.INTEGER(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(
["author_id"], ["users.id"], name=op.f("blogs_author_id_fkey")
),
sa.PrimaryKeyConstraint("id", name=op.f("blogs_pkey")),
)
op.create_index(op.f("ix_blogs_title"), "blogs", ["title"], unique=False)
op.create_index(op.f("ix_blogs_id"), "blogs", ["id"], unique=False)
op.create_index(op.f("ix_blogs_author_id"), "blogs", ["author_id"], unique=False)
op.create_table(
"users",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column("username", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("email", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("hashed_password", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column(
"permissions",
postgresql.JSON(astext_type=sa.Text()),
autoincrement=False,
nullable=False,
),
sa.Column("subscriber", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("users_pkey")),
)
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
# ### end Alembic commands ###
+14 -2
View File
@@ -1,5 +1,6 @@
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
@@ -10,11 +11,22 @@ from .database import SessionLocal, engine, Base
Base.metadata.create_all(bind=engine) 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( app.add_middleware(
CORSMiddleware, 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_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
+1
View File
@@ -16,6 +16,7 @@ class Blog(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True) title = Column(String, index=True)
author_id = Column(Integer, nullable=False, index=True) author_id = Column(Integer, nullable=False, index=True)
author_name = Column(String, nullable=False, index=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
body = Column(String, nullable=False) body = Column(String, nullable=False)
created_at = Column( created_at = Column(
+2
View File
@@ -5,6 +5,7 @@ from pydantic import AwareDatetime, BaseModel, EmailStr
class BlogBase(BaseModel): class BlogBase(BaseModel):
title: str title: str
author_id: int author_id: int
author_name: str
description: Optional[str] = None description: Optional[str] = None
body: str body: str
created_at: AwareDatetime created_at: AwareDatetime
@@ -30,6 +31,7 @@ class BlogUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
author_name: Optional[str] = None
body: Optional[str] = None body: Optional[str] = None
created_at: Optional[str] = None created_at: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None
+5 -3
View File
@@ -1,5 +1,3 @@
version: '3.8'
services: services:
db: db:
image: postgres:15 image: postgres:15
@@ -37,7 +35,11 @@ services:
- app-network - app-network
frontend: frontend:
build: ./frontend build:
context: ./frontend
args:
VITE_API_URL: ${API_URL}
ports: ports:
- "3000:80" - "3000:80"
depends_on: depends_on:
+13 -10
View File
@@ -1,19 +1,22 @@
# Build stage FROM node:20 AS builder
FROM node:20 AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./
COPY package.json package-lock.json ./
RUN npm install RUN npm install
COPY . . COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build RUN npm run build
# Production stage # Stage 2: Serve with NGINX
FROM nginx:alpine 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 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
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

+57 -45
View File
@@ -5,6 +5,9 @@ import React, {
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
import cruiseImg from "./images/cruise.jpg";
import discGolfImg from "./images/disc_golf_ace.jpg";
import odinImg from "./images/odin.jpg";
import { import {
BrowserRouter as Router, BrowserRouter as Router,
Routes, Routes,
@@ -16,6 +19,7 @@ import { useForm } from "react-hook-form";
import { BlogViewer } from "./utils/BlogViewer"; import { BlogViewer } from "./utils/BlogViewer";
import { RequireAdmin } from "./utils/RouteGuard"; import { RequireAdmin } from "./utils/RouteGuard";
import { AdminPage } from "./utils/AdminPage"; import { AdminPage } from "./utils/AdminPage";
import { EditBlog } from "./utils/EditBlog";
import Unauthorized from "./utils/UnauthorizedPage"; import Unauthorized from "./utils/UnauthorizedPage";
import { import {
FaGithub, FaGithub,
@@ -27,9 +31,7 @@ import {
} from "react-icons/fa"; } from "react-icons/fa";
import type { Blog } from "./utils/types"; import type { Blog } from "./utils/types";
import { countWords } from "./utils/countWords"; import { countWords } from "./utils/countWords";
import { API_URL } from "./utils/constants";
// Base API URL from env
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
// Auth Context // Auth Context
interface AuthContextProps { interface AuthContextProps {
@@ -63,6 +65,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const logout = () => { const logout = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user_id");
setIsAuthenticated(false); setIsAuthenticated(false);
navigate("/signin"); navigate("/signin");
}; };
@@ -80,7 +83,12 @@ function BlogPage() {
const [blogs, setBlogs] = useState<Blog[]>([]); const [blogs, setBlogs] = useState<Blog[]>([]);
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/blogs`) fetch(`${API_URL}/blogs`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json()) .then((res) => res.json())
.then((data: Blog[]) => setBlogs(data)) .then((data: Blog[]) => setBlogs(data))
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@@ -106,7 +114,10 @@ function BlogPage() {
className="block p-4 border rounded hover:bg-gray-100 dark:hover:bg-gray-800" className="block p-4 border rounded hover:bg-gray-100 dark:hover:bg-gray-800"
> >
<h3 className="text-xl font-semibold">{blog.title}</h3> <h3 className="text-xl font-semibold">{blog.title}</h3>
<p className="text-sm text-gray-500">By {blog.author_id}</p> <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> </Link>
))} ))}
</div> </div>
@@ -117,10 +128,9 @@ function BlogPage() {
// Page for creating a blog post // Page for creating a blog post
function CreateBlog() { function CreateBlog() {
const [fileName, setFileName] = useState<string>(""); const [fileName, setFileName] = useState<string>("");
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState("");
const username = localStorage.getItem("user_id") || ""; const [description, setDescription] = useState("");
const [author, setAuthor] = useState<string>(username); const [authorName, setAuthorName] = useState("");
const [description, setDescription] = useState<string>("");
const [content, setContent] = useState<string>(""); const [content, setContent] = useState<string>("");
const navigate = useNavigate(); const navigate = useNavigate();
@@ -134,34 +144,31 @@ function CreateBlog() {
setFileName(file.name); setFileName(file.name);
const baseName = file.name.replace(/\.[^.]+$/, ""); const baseName = file.name.replace(/\.[^.]+$/, "");
setTitle(baseName); setTitle(baseName);
setAuthor(username); setAuthorName(authorName);
}; };
reader.readAsText(file); reader.readAsText(file);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Stubbed API call const userId = Number(localStorage.getItem("user_id"));
try { try {
const body = { const body = {
title: title, title,
author_id: localStorage.getItem("user_id"), author_id: userId, // still from localStorage
author_name: authorName, // from the form field
description: description, description: description,
body: content, body: content,
created_at: new Date(Date.now()).toISOString(), created_at: new Date().toISOString(),
updated_at: new Date(Date.now()).toISOString(), updated_at: new Date().toISOString(),
published_at: new Date(Date.now()).toISOString(), published_at: new Date().toISOString(),
word_count: countWords(content), word_count: countWords(content),
version: 1,
read_time: 0,
language: "US",
}; };
const res = await fetch(`${API_URL}/blogs`, { await fetch(`${API_URL}/blogs`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
alert(res);
navigate("/blog"); navigate("/blog");
} catch (err: any) { } catch (err: any) {
alert(err.message); alert(err.message);
@@ -203,8 +210,8 @@ function CreateBlog() {
<input <input
id="author" id="author"
type="text" type="text"
value={author} value={authorName}
onChange={(e) => setAuthor(e.target.value)} onChange={(e) => setAuthorName(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2" className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/> />
</div> </div>
@@ -277,6 +284,7 @@ function App() {
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/blog" element={<BlogPage />} /> <Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogViewer />} /> <Route path="/blog/:slug" element={<BlogViewer />} />
<Route path="/blog/:slug/edit" element={<EditBlog />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} /> <Route path="/contact" element={<Contact />} />
<Route <Route
@@ -416,32 +424,36 @@ function LandingPage() {
function About() { function About() {
return ( return (
<div className="py-10"> <div className="py-10">
<div className="flex justify-center items-end space-x-4"> <div className="flex justify-center items-end space-x-4 flex-wrap">
<img {[cruiseImg, discGolfImg, odinImg].map((src, i) => (
src="/images/photo1.jpg" <img
alt="Photo 1" key={i}
className="w-24 h-24 rounded-full" src={src}
/> alt={["Cruise", "Disc golf ace", "Odin"][i]}
<img className="
src="/images/photo2.jpg" rounded-full
alt="Photo 2" aspect-square
className="w-24 h-24 rounded-full transform translate-y-4" object-cover
/> w-1/3 max-w-[120px] /* small screens: 33% of container, up to 120px */
<img sm:w-1/4 sm:max-w-[150px] /* ≥640px: 25% up to 150px */
src="/images/photo3.jpg" md:w-1/6 md:max-w-[200px] /* ≥768px: ~16% up to 200px */
alt="Photo 3" lg:w-1/8 lg:max-w-[250px] /* ≥1024px: 12.5% up to 250px */
className="w-24 h-24 rounded-full" "
/> />
))}
</div> </div>
<div className="text-center mt-8 px-4"> <div className="text-center mt-8 px-4">
<h2 className="text-3xl font-bold mb-4">About Me</h2> <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"> <p className="text-lg text-gray-700 dark:text-gray-300 max-w-2xl mx-auto">
I am a software engineer at Whisker who designs and implements Im Alex, a full-stack engineer at Whisker, working on backend one
whatever is highest priority. I work with every team in the minute, mobile tweaks the next, and firmware the day after. Learning
engineering organization to coordinate mission-critical projects new tech is something I'm passionate about, and Im all about
across backend, mobile, and firmware. Im comfortable in many tech spreading that knowledge fast. Off-duty youll catch me reading a
stacks and learn new ones quicklynever afraid to jump in the deep end bunch, writing (ironically, on my blog, which you are currently
and adapt on the fly. viewing), exploring the outdoors with my dogs (and my girlfriend,
trust me shes real!), or rolling dice as a D&amp;D sorcerer. Here
youll find book thoughts, code experiments, and random shower
thoughts. Thanks for stopping by!
</p> </p>
</div> </div>
<div className="mt-8 flex flex-col items-center space-y-2"> <div className="mt-8 flex flex-col items-center space-y-2">
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

+7 -1
View File
@@ -1,9 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { API_URL } from "./constants";
export function BlogList() { export function BlogList() {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
useEffect(() => { useEffect(() => {
fetch(`localhost:8000/get-blogs`) fetch(`${API_URL}/get-blogs`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.text()) .then((res) => res.text())
.then(setContent); .then(setContent);
}, []); }, []);
+46 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { API_URL } from "./constants"; import { API_URL } from "./constants";
import type { Blog } from "./types"; import type { Blog } from "./types";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
@@ -11,26 +11,63 @@ import "../styles/markdown.css";
export function BlogViewer() { export function BlogViewer() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const [blog, setBlog] = useState<Blog | null>(null); const [blog, setBlog] = useState<Blog | null>(null);
const navigate = useNavigate();
const me = localStorage.getItem("user_id");
useEffect(() => { useEffect(() => {
if (!slug) return; if (!slug) return;
fetch(`${API_URL}/blogs/${slug}`) fetch(`${API_URL}/blogs/${slug}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json()) .then((res) => res.json())
.then((data: Blog) => setBlog(data)) .then((data: Blog) => setBlog(data))
.catch((err) => console.error(err)); .catch((err) => console.error(err));
}, [slug]); }, [slug]);
if (!blog) { if (!blog) return <p>Loading</p>;
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 ( return (
<div className="max-w-2xl mx-auto py-10 space-y-6"> <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> <h1 className="text-3xl font-bold">{blog.title.toUpperCase()}</h1>
<p className="text-sm text-gray-500">By User {blog.author_id}</p> <p className="text-sm text-gray-500">By {blog.author_name}</p>
<p className="italic text-gray-600 dark:text-gray-400">
{blog.description}
</p>
<div className="markdown-body mx-auto p-4"> <div className="markdown-body mx-auto p-4">
<Markdown <Markdown
remarkPlugins={[remarkGfm, remarkRehype]} remarkPlugins={[remarkGfm, remarkRehype]}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState, type FormEvent } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { API_URL } from "./constants";
import type { Blog } from "./types";
import { countWords } from "./countWords";
export function EditBlog() {
const { slug } = useParams<{ slug: string }>();
const [blog, setBlog] = useState<Blog | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [body, setBody] = useState("");
const [authorName, setAuthorName] = useState("");
const navigate = useNavigate();
useEffect(() => {
if (!slug) return;
fetch(`${API_URL}/blogs/${slug}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data: Blog) => {
setBlog(data);
setTitle(data.title);
setDescription(data.description);
setBody(data.body);
setAuthorName(data.author_name);
})
.catch(console.error);
}, [slug]);
if (!blog) return <p>Loading</p>;
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const updatedBlog = {
title,
description,
body,
author_name: authorName, // from the form field
updated_at: new Date().toISOString(),
word_count: countWords(body),
version: blog ? blog.version + 1 : 1,
};
const res = await fetch(`${API_URL}/blogs/${slug}`, {
method: "PUT", // or "PATCH" if your API prefers
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedBlog),
});
if (res.ok) {
navigate(`/blogs/${slug}`); // go back to the viewer
} else {
console.error("Update failed:", await res.text());
alert("Could not update the post. See console for details.");
}
}
return (
<div className="max-w-2xl mx-auto py-10">
<h2 className="text-2xl font-bold mb-4">Edit Blog Post</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-medium">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border px-2 py-1 rounded"
/>
</div>
<div>
<label className="block font-medium">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full border px-2 py-1 rounded"
/>
</div>
<div>
<label className="block font-medium">Author Name</label>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
className="w-full border px-2 py-1 rounded"
required
/>
</div>
<div>
<label className="block font-medium">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
className="w-full border px-2 py-1 rounded font-mono"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Save Changes
</button>
</form>
</div>
);
}
+10 -1
View File
@@ -1,7 +1,16 @@
export interface Blog { export interface Blog {
id: number; id: number;
title: string; title: string;
author_id: number;
description: string; description: string;
body: 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[];
} }