Compare commits

...

10 Commits

Author SHA1 Message Date
魏风
23a5083673 feat: Update application branding from FastAPI to SpatialHub logos and icons.
All checks were successful
Deploy to Production / deploy (push) Successful in 1m15s
2026-03-13 15:12:16 +08:00
魏风
77846b73ef feat: Rename project to SpatialHub, update project names in configuration and documentation, and modify social links.
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
2026-03-13 12:21:17 +08:00
魏风
3c9a0343e9 Remove item management feature from frontend and backend, and add a pending skeleton component.
All checks were successful
Deploy to Production / deploy (push) Successful in 1m34s
2026-03-13 11:46:15 +08:00
魏风
ef93d4e5c2 feat: add location crud
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
2026-03-12 19:47:41 +08:00
codex
f0a0667b84 deploy: support configurable backend host for frontend API URL
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
2026-03-11 17:55:07 +08:00
codex
aeb7a8b678 ci: check backend/frontend via docker status in runner container mode
All checks were successful
Deploy to Production / deploy (push) Successful in 24s
2026-03-11 17:49:13 +08:00
codex
00469941dd deploy: move backend host port to 18000 to avoid 8000 conflict
Some checks failed
Deploy to Production / deploy (push) Failing after 5m10s
2026-03-11 17:41:22 +08:00
codex
2fe5a7f54e ci: default image names when secrets are empty
Some checks failed
Deploy to Production / deploy (push) Failing after 9s
2026-03-11 17:32:28 +08:00
codex
e860fd0607 ci: force compose to use .env.production in deploy workflow
Some checks failed
Deploy to Production / deploy (push) Failing after 5s
2026-03-11 17:15:02 +08:00
codex
6defbf92e0 ci: avoid external actions checkout by cloning local Gitea repo
Some checks failed
Deploy to Production / deploy (push) Failing after 8m42s
2026-03-11 16:44:56 +08:00
43 changed files with 757 additions and 817 deletions

4
.env
View File

@@ -13,8 +13,8 @@ FRONTEND_HOST=http://localhost:5173
# Environment: local, staging, production # Environment: local, staging, production
ENVIRONMENT=local ENVIRONMENT=local
PROJECT_NAME="Full Stack FastAPI Project" PROJECT_NAME="SpatialHub"
STACK_NAME=full-stack-fastapi-project STACK_NAME=spatialhub
# Backend # Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"

View File

@@ -10,12 +10,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 run: |
git clone "http://172.17.0.1:3000/${{ github.repository }}.git" .
git checkout "${{ github.sha }}"
- name: Create .env.production from secrets - name: Create .env.production from secrets
run: | run: |
cat > .env.production << 'ENVEOF' cat > .env.production << 'ENVEOF'
DOMAIN=${{ secrets.DOMAIN }} DOMAIN=${{ secrets.DOMAIN }}
BACKEND_HOST=${{ secrets.BACKEND_HOST }}
FRONTEND_HOST=${{ secrets.FRONTEND_HOST }} FRONTEND_HOST=${{ secrets.FRONTEND_HOST }}
ENVIRONMENT=production ENVIRONMENT=production
PROJECT_NAME=${{ secrets.PROJECT_NAME }} PROJECT_NAME=${{ secrets.PROJECT_NAME }}
@@ -41,37 +44,42 @@ jobs:
DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }} DOCKER_IMAGE_FRONTEND=${{ secrets.DOCKER_IMAGE_FRONTEND }}
ENVEOF ENVEOF
# Fallback defaults if image name secrets are empty
sed -i 's/^DOCKER_IMAGE_BACKEND=$/DOCKER_IMAGE_BACKEND=backend/' .env.production
sed -i 's/^DOCKER_IMAGE_FRONTEND=$/DOCKER_IMAGE_FRONTEND=frontend/' .env.production
- name: Build Docker images - name: Build Docker images
run: docker compose -f compose.prod.yml build run: docker compose --env-file .env.production -f compose.prod.yml build
- name: Stop existing services - name: Stop existing services
run: docker compose -f compose.prod.yml down --remove-orphans || true run: docker compose --env-file .env.production -f compose.prod.yml down --remove-orphans || true
- name: Start services - name: Start services
run: docker compose -f compose.prod.yml up -d run: docker compose --env-file .env.production -f compose.prod.yml up -d
- name: Wait for backend health check - name: Wait for backend health check
run: | run: |
echo "Waiting for backend to be healthy..." echo "Waiting for backend container health=healthy..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8000/api/v1/utils/health-check/ > /dev/null 2>&1; then status=$(docker inspect -f '{{.State.Health.Status}}' full-stack-fastapi-backend-1 2>/dev/null || echo "unknown")
if [ "$status" = "healthy" ]; then
echo "✅ Backend is healthy!" echo "✅ Backend is healthy!"
exit 0 exit 0
fi fi
echo "Attempt $i/30 - waiting 10s..." echo "Attempt $i/30 - status=$status, waiting 10s..."
sleep 10 sleep 10
done done
echo "❌ Backend health check failed after 300s" echo "❌ Backend health check failed after 300s"
docker compose -f compose.prod.yml logs backend docker compose --env-file .env.production -f compose.prod.yml logs backend
exit 1 exit 1
- name: Verify frontend - name: Verify frontend
run: | run: |
if curl -sf http://127.0.0.1:3001 > /dev/null 2>&1; then if docker compose --env-file .env.production -f compose.prod.yml ps frontend --status running | grep -q frontend; then
echo "✅ Frontend is accessible!" echo "✅ Frontend is accessible!"
else else
echo "❌ Frontend is not accessible" echo "❌ Frontend is not accessible"
docker compose -f compose.prod.yml logs frontend docker compose --env-file .env.production -f compose.prod.yml logs frontend
exit 1 exit 1
fi fi

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ node_modules/
.DS_Store .DS_Store
.env.production .env.production
.venv/ .venv/
.playwright-cli/

View File

@@ -1,6 +1,6 @@
# Contributing # Contributing
Thank you for your interest in contributing to the Full Stack FastAPI Template! 🙇 Thank you for your interest in contributing to the SpatialHub! 🙇
## Discussions First ## Discussions First

View File

@@ -1,4 +1,4 @@
# Full Stack FastAPI Template # SpatialHub
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Docker+Compose%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Docker%20Compose/badge.svg" alt="Test Docker Compose"></a> <a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Docker+Compose%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Docker%20Compose/badge.svg" alt="Test Docker Compose"></a>
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Backend%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Backend/badge.svg" alt="Test Backend"></a> <a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Backend%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Backend/badge.svg" alt="Test Backend"></a>
@@ -230,4 +230,4 @@ Check the file [release-notes.md](./release-notes.md).
## License ## License
The Full Stack FastAPI Template is licensed under the terms of the MIT license. The SpatialHub is licensed under the terms of the MIT license.

View File

@@ -0,0 +1,37 @@
"""add location table
Revision ID: 0b117fab4208
Revises: fe56fa70289e
Create Date: 2026-03-12 11:07:24.856505
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = '0b117fab4208'
down_revision = 'fe56fa70289e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('location',
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('location')
# ### end Alembic commands ###

View File

@@ -1,14 +1,15 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.routes import items, login, private, users, utils from app.api.routes import locations, login, private, users, utils
from app.core.config import settings from app.core.config import settings
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(login.router) api_router.include_router(login.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(utils.router) api_router.include_router(utils.router)
api_router.include_router(items.router) api_router.include_router(locations.router)
if settings.ENVIRONMENT == "local": if settings.ENVIRONMENT == "local":
api_router.include_router(private.router) api_router.include_router(private.router)

View File

@@ -1,112 +0,0 @@
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException
from sqlmodel import col, func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/", response_model=ItemsPublic)
def read_items(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve items.
"""
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
statement = (
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
)
items = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Item)
.where(Item.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Item)
.where(Item.owner_id == current_user.id)
.order_by(col(Item.created_at).desc())
.offset(skip)
.limit(limit)
)
items = session.exec(statement).all()
return ItemsPublic(data=items, count=count)
@router.get("/{id}", response_model=ItemPublic)
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get item by ID.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
return item
@router.post("/", response_model=ItemPublic)
def create_item(
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
) -> Any:
"""
Create new item.
"""
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item
@router.put("/{id}", response_model=ItemPublic)
def update_item(
*,
session: SessionDep,
current_user: CurrentUser,
id: uuid.UUID,
item_in: ItemUpdate,
) -> Any:
"""
Update an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/{id}")
def delete_item(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
session.delete(item)
session.commit()
return Message(message="Item deleted successfully")

View File

@@ -0,0 +1,119 @@
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException
from sqlmodel import col, func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import (
Location,
LocationCreate,
LocationPublic,
LocationsPublic,
LocationUpdate,
Message,
)
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get("/", response_model=LocationsPublic)
def read_locations(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve locations.
"""
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Location)
count = session.exec(count_statement).one()
statement = (
select(Location).order_by(col(Location.created_at).desc()).offset(skip).limit(limit)
)
locations = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Location)
.where(Location.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Location)
.where(Location.owner_id == current_user.id)
.order_by(col(Location.created_at).desc())
.offset(skip)
.limit(limit)
)
locations = session.exec(statement).all()
return LocationsPublic(data=locations, count=count)
@router.get("/{id}", response_model=LocationPublic)
def read_location(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get location by ID.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
return location
@router.post("/", response_model=LocationPublic)
def create_location(
*, session: SessionDep, current_user: CurrentUser, location_in: LocationCreate
) -> Any:
"""
Create new location.
"""
location = Location.model_validate(location_in, update={"owner_id": current_user.id})
session.add(location)
session.commit()
session.refresh(location)
return location
@router.put("/{id}", response_model=LocationPublic)
def update_location(
*,
session: SessionDep,
current_user: CurrentUser,
id: uuid.UUID,
location_in: LocationUpdate,
) -> Any:
"""
Update a location.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
update_dict = location_in.model_dump(exclude_unset=True)
location.sqlmodel_update(update_dict)
session.add(location)
session.commit()
session.refresh(location)
return location
@router.delete("/{id}")
def delete_location(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete a location.
"""
location = session.get(Location, id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if not current_user.is_superuser and (location.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
session.delete(location)
session.commit()
return Message(message="Location deleted successfully")

View File

@@ -13,7 +13,7 @@ from app.api.deps import (
from app.core.config import settings from app.core.config import settings
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
from app.models import ( from app.models import (
Item, Location,
Message, Message,
UpdatePassword, UpdatePassword,
User, User,
@@ -224,7 +224,7 @@ def delete_user(
raise HTTPException( raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves" status_code=403, detail="Super users are not allowed to delete themselves"
) )
statement = delete(Item).where(col(Item.owner_id) == user_id) statement = delete(Location).where(col(Location.owner_id) == user_id)
session.exec(statement) session.exec(statement)
session.delete(user) session.delete(user)
session.commit() session.commit()

View File

@@ -4,7 +4,13 @@ from typing import Any
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate from app.models import (
Location,
LocationCreate,
User,
UserCreate,
UserUpdate,
)
def create_user(*, session: Session, user_create: UserCreate) -> User: def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -60,9 +66,9 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
return db_user return db_user
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
session.add(db_item) session.add(db_location)
session.commit() session.commit()
session.refresh(db_item) session.refresh(db_location)
return db_item return db_location

View File

@@ -53,7 +53,7 @@ class User(UserBase, table=True):
default_factory=get_datetime_utc, default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore sa_type=DateTime(timezone=True), # type: ignore
) )
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
# Properties to return via API, id is always required # Properties to return via API, id is always required
@@ -68,23 +68,23 @@ class UsersPublic(SQLModel):
# Shared properties # Shared properties
class ItemBase(SQLModel): class LocationBase(SQLModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, max_length=255)
# Properties to receive on item creation # Properties to receive on location creation
class ItemCreate(ItemBase): class LocationCreate(LocationBase):
pass pass
# Properties to receive on item update # Properties to receive on location update
class ItemUpdate(ItemBase): class LocationUpdate(LocationBase):
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
# Database model, database table inferred from class name # Database model, database table inferred from class name
class Item(ItemBase, table=True): class Location(LocationBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
created_at: datetime | None = Field( created_at: datetime | None = Field(
default_factory=get_datetime_utc, default_factory=get_datetime_utc,
@@ -93,18 +93,18 @@ class Item(ItemBase, table=True):
owner_id: uuid.UUID = Field( owner_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE" foreign_key="user.id", nullable=False, ondelete="CASCADE"
) )
owner: User | None = Relationship(back_populates="items") owner: User | None = Relationship(back_populates="locations")
# Properties to return via API, id is always required # Properties to return via API, id is always required
class ItemPublic(ItemBase): class LocationPublic(LocationBase):
id: uuid.UUID id: uuid.UUID
owner_id: uuid.UUID owner_id: uuid.UUID
created_at: datetime | None = None created_at: datetime | None = None
class ItemsPublic(SQLModel): class LocationsPublic(SQLModel):
data: list[ItemPublic] data: list[LocationPublic]
count: int count: int

View File

@@ -4,15 +4,15 @@ from fastapi.testclient import TestClient
from sqlmodel import Session from sqlmodel import Session
from app.core.config import settings from app.core.config import settings
from tests.utils.item import create_random_item from tests.utils.location import create_random_location
def test_create_item( def test_create_location(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
data = {"title": "Foo", "description": "Fighters"} data = {"title": "Test Location", "description": "A test location"}
response = client.post( response = client.post(
f"{settings.API_V1_STR}/items/", f"{settings.API_V1_STR}/locations/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
@@ -24,40 +24,40 @@ def test_create_item(
assert "owner_id" in content assert "owner_id" in content
def test_read_item( def test_read_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
response = client.get( response = client.get(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert content["title"] == item.title assert content["title"] == location.title
assert content["description"] == item.description assert content["description"] == location.description
assert content["id"] == str(item.id) assert content["id"] == str(location.id)
assert content["owner_id"] == str(item.owner_id) assert content["owner_id"] == str(location.owner_id)
def test_read_item_not_found( def test_read_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
response = client.get( response = client.get(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}", f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 404 assert response.status_code == 404
content = response.json() content = response.json()
assert content["detail"] == "Item not found" assert content["detail"] == "Location not found"
def test_read_item_not_enough_permissions( def test_read_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
response = client.get( response = client.get(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers, headers=normal_user_token_headers,
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -65,13 +65,13 @@ def test_read_item_not_enough_permissions(
assert content["detail"] == "Not enough permissions" assert content["detail"] == "Not enough permissions"
def test_read_items( def test_read_locations(
client: TestClient, superuser_token_headers: dict[str, str], db: Session client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
create_random_item(db) create_random_location(db)
create_random_item(db) create_random_location(db)
response = client.get( response = client.get(
f"{settings.API_V1_STR}/items/", f"{settings.API_V1_STR}/locations/",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -79,13 +79,13 @@ def test_read_items(
assert len(content["data"]) >= 2 assert len(content["data"]) >= 2
def test_update_item( def test_update_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
data = {"title": "Updated title", "description": "Updated description"} data = {"title": "Updated title", "description": "Updated description"}
response = client.put( response = client.put(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
@@ -93,31 +93,31 @@ def test_update_item(
content = response.json() content = response.json()
assert content["title"] == data["title"] assert content["title"] == data["title"]
assert content["description"] == data["description"] assert content["description"] == data["description"]
assert content["id"] == str(item.id) assert content["id"] == str(location.id)
assert content["owner_id"] == str(item.owner_id) assert content["owner_id"] == str(location.owner_id)
def test_update_item_not_found( def test_update_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
data = {"title": "Updated title", "description": "Updated description"} data = {"title": "Updated title", "description": "Updated description"}
response = client.put( response = client.put(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}", f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
assert response.status_code == 404 assert response.status_code == 404
content = response.json() content = response.json()
assert content["detail"] == "Item not found" assert content["detail"] == "Location not found"
def test_update_item_not_enough_permissions( def test_update_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
data = {"title": "Updated title", "description": "Updated description"} data = {"title": "Updated title", "description": "Updated description"}
response = client.put( response = client.put(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers, headers=normal_user_token_headers,
json=data, json=data,
) )
@@ -126,37 +126,37 @@ def test_update_item_not_enough_permissions(
assert content["detail"] == "Not enough permissions" assert content["detail"] == "Not enough permissions"
def test_delete_item( def test_delete_location(
client: TestClient, superuser_token_headers: dict[str, str], db: Session client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
response = client.delete( response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert content["message"] == "Item deleted successfully" assert content["message"] == "Location deleted successfully"
def test_delete_item_not_found( def test_delete_location_not_found(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
response = client.delete( response = client.delete(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}", f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 404 assert response.status_code == 404
content = response.json() content = response.json()
assert content["detail"] == "Item not found" assert content["detail"] == "Location not found"
def test_delete_item_not_enough_permissions( def test_delete_location_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None: ) -> None:
item = create_random_item(db) location = create_random_location(db)
response = client.delete( response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}", f"{settings.API_V1_STR}/locations/{location.id}",
headers=normal_user_token_headers, headers=normal_user_token_headers,
) )
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
from app.core.config import settings from app.core.config import settings
from app.core.db import engine, init_db from app.core.db import engine, init_db
from app.main import app from app.main import app
from app.models import Item, User from app.models import Location, User
from tests.utils.user import authentication_token_from_email from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers from tests.utils.utils import get_superuser_token_headers
@@ -17,7 +17,7 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session: with Session(engine) as session:
init_db(session) init_db(session)
yield session yield session
statement = delete(Item) statement = delete(Location)
session.execute(statement) session.execute(statement)
statement = delete(User) statement = delete(User)
session.execute(statement) session.execute(statement)

View File

@@ -1,16 +1,16 @@
from sqlmodel import Session from sqlmodel import Session
from app import crud from app import crud
from app.models import Item, ItemCreate from app.models import Location, LocationCreate
from tests.utils.user import create_random_user from tests.utils.user import create_random_user
from tests.utils.utils import random_lower_string from tests.utils.utils import random_lower_string
def create_random_item(db: Session) -> Item: def create_random_location(db: Session) -> Location:
user = create_random_user(db) user = create_random_user(db)
owner_id = user.id owner_id = user.id
assert owner_id is not None assert owner_id is not None
title = random_lower_string() title = random_lower_string()
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description) location_in = LocationCreate(title=title, description=description)
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) return crud.create_location(session=db, location_in=location_in, owner_id=owner_id)

View File

@@ -38,7 +38,7 @@ services:
prestart: prestart:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:18000:8000"
env_file: env_file:
- .env.production - .env.production
environment: environment:
@@ -81,7 +81,7 @@ services:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
- VITE_API_URL=https://api.${DOMAIN?Variable not set} - VITE_API_URL=https://${BACKEND_HOST:-api.${DOMAIN?Variable not set}}
- NODE_ENV=production - NODE_ENV=production
networks: networks:

View File

@@ -51,14 +51,14 @@ sudo chown $USER:$USER /opt/fastapi-app
### 2.2 初始化 Git 仓库(在 Gitea 上) ### 2.2 初始化 Git 仓库(在 Gitea 上)
1. 登录 Gitea`http://your-server-ip:3000` 1. 登录 Gitea`http://your-server-ip:3000`
2. 创建新仓库,例如 `full-stack-fastapi` 2. 创建新仓库,例如 `spatialhub`
3. 在本地开发机上添加 Gitea 远程仓库: 3. 在本地开发机上添加 Gitea 远程仓库:
```bash ```bash
cd /Users/weifeng/Workspace/full-stack-fastapi-template cd /Users/weifeng/Workspace/full-stack-fastapi-template
# 添加 Gitea 远程 # 添加 Gitea 远程
git remote add gitea http://your-server-ip:3000/your-username/full-stack-fastapi.git git remote add gitea http://your-server-ip:3000/your-username/spatialhub.git
# 推送代码 # 推送代码
git push gitea main git push gitea main
@@ -130,7 +130,7 @@ ALTER DATABASE app OWNER TO fastapi_user;
```bash ```bash
cd /opt/fastapi-app cd /opt/fastapi-app
git clone http://localhost:3000/your-username/full-stack-fastapi.git . git clone http://localhost:3000/your-username/spatialhub.git .
# 或者如果已经有代码 # 或者如果已经有代码
git pull origin main git pull origin main
``` ```
@@ -323,8 +323,8 @@ sudo systemctl status gitea-runner
|-------------|-----| |-------------|-----|
| `DOMAIN` | `makefire.fun` | | `DOMAIN` | `makefire.fun` |
| `FRONTEND_HOST` | `https://makefire.fun` | | `FRONTEND_HOST` | `https://makefire.fun` |
| `PROJECT_NAME` | `Full Stack FastAPI Project` | | `PROJECT_NAME` | `SpatialHub` |
| `STACK_NAME` | `full-stack-fastapi-project` | | `STACK_NAME` | `spatialhub` |
| `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` | | `BACKEND_CORS_ORIGINS` | `https://makefire.fun,https://api.makefire.fun` |
| `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* | | `SECRET_KEY` | *(用 `openssl rand -hex 32` 生成)* |
| `FIRST_SUPERUSER` | `admin@makefire.fun` | | `FIRST_SUPERUSER` | `admin@makefire.fun` |

View File

@@ -2,10 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Full Stack FastAPI Project</title> <title>SpatialHub</title>
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/assets/images/spatialhub-icon.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-white-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-transparent-500.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-teal-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-192.png"
inkscape:export-xdpi="36.863998"
inkscape:export-ydpi="36.863998"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="341.26324mm"
height="63.977489mm"
viewBox="0 0 341.26324 63.977485"
version="1.1"
id="svg8"
sodipodi:docname="logo-white-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="logo-white-margin-vector.png"
inkscape:export-xdpi="57.604134"
inkscape:export-ydpi="57.604134"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="644.8755"
inkscape:cy="95.713101"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g2141"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149"
transform="translate(2.5752783e-4,1.1668595e-4)">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.136325;stop-color:#000000"
d="M 32.092357,-1.166849e-4 A 32.092354,31.988569 0 0 0 -2.5752734e-4,31.988628 32.092354,31.988569 0 0 0 32.092357,63.977374 32.092354,31.988569 0 0 0 64.184455,31.988628 32.092354,31.988569 0 0 0 32.092357,-1.166849e-4 Z M 29.878022,14.484271 H 50.070588 L 29.794823,28.73353 H 43.654442 L 14.114126,49.49247 20.530789,35.243727 23.462393,28.73353 Z" />
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#ffffff;stroke-width:1.99288"
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979-3"
aria-label="FastAPI" />
</g>
</g>
</g>
<rect
y="-49.422306"
x="-51.908459"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="341.26297mm"
height="63.977139mm"
viewBox="0 0 341.26297 63.977134"
version="1.1"
id="svg8"
sodipodi:docname="logo-teal-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<g
id="g2106"
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
<circle
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.141404;stop-color:#000000"
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
cx="964.56165"
cy="-169.22266"
r="33.234192"
inkscape:export-xdpi="1543.8315"
inkscape:export-ydpi="1543.8315" />
<path
id="rect1249-6-3-4-4-3-6-6-1-2"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
d="m 962.2685,-187.40837 -6.64403,14.80375 -3.03599,6.76393 -6.64456,14.80375 30.59142,-21.56768 h -14.35312 l 20.99715,-14.80375 z" />
</g>
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979-3"
aria-label="FastAPI" />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
<style>
.text {
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
font-size: 58px;
font-weight: 600;
fill: #ffffff;
}
</style>
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
<text x="85" y="59" class="text">SpatialHub</text>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 450 80" xmlns="http://www.w3.org/2000/svg">
<style>
.text {
font-family: 'Ubuntu', 'Segoe UI', system-ui, sans-serif;
font-size: 58px;
font-weight: 600;
fill: #009688;
}
</style>
<circle cx="40" cy="40" r="35" fill="#009688" />
<g fill="#ffffff">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 40 18 C 30 18 22 26 22 36 C 22 48 40 63 40 63 C 40 63 58 48 58 36 C 58 26 50 18 40 18 Z M 40 43 C 36.134 43 33 39.866 33 36 C 33 32.134 36.134 29 40 29 C 43.866 29 47 32.134 47 36 C 47 39.866 43.866 43 40 43 Z" />
<ellipse cx="40" cy="40" rx="27" ry="10" fill="none" stroke="#ffffff" stroke-width="2.5" transform="rotate(-25 40 40)" />
<circle cx="15.5" cy="51.5" r="3.5" fill="#ffffff" />
</g>
<text x="85" y="59" class="text">SpatialHub</text>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@@ -196,6 +196,131 @@ export const ItemsPublicSchema = {
title: 'ItemsPublic' title: 'ItemsPublic'
} as const; } as const;
export const LocationCreateSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
required: ['title'],
title: 'LocationCreate'
} as const;
export const LocationPublicSchema = {
properties: {
title: {
type: 'string',
maxLength: 255,
minLength: 1,
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
},
id: {
type: 'string',
format: 'uuid',
title: 'Id'
},
owner_id: {
type: 'string',
format: 'uuid',
title: 'Owner Id'
},
created_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Created At'
}
},
type: 'object',
required: ['title', 'id', 'owner_id'],
title: 'LocationPublic'
} as const;
export const LocationUpdateSchema = {
properties: {
title: {
anyOf: [
{
type: 'string',
maxLength: 255,
minLength: 1
},
{
type: 'null'
}
],
title: 'Title'
},
description: {
anyOf: [
{
type: 'string',
maxLength: 255
},
{
type: 'null'
}
],
title: 'Description'
}
},
type: 'object',
title: 'LocationUpdate'
} as const;
export const LocationsPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/LocationPublic'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'LocationsPublic'
} as const;
export const MessageSchema = { export const MessageSchema = {
properties: { properties: {
message: { message: {

View File

@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class ItemsService { export class ItemsService {
/** /**
@@ -116,6 +116,117 @@ export class ItemsService {
} }
} }
export class LocationsService {
/**
* Read Locations
* Retrieve locations.
* @param data The data for the request.
* @param data.skip
* @param data.limit
* @returns LocationsPublic Successful Response
* @throws ApiError
*/
public static readLocations(data: LocationsReadLocationsData = {}): CancelablePromise<LocationsReadLocationsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/locations/',
query: {
skip: data.skip,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Create Location
* Create new location.
* @param data The data for the request.
* @param data.requestBody
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static createLocation(data: LocationsCreateLocationData): CancelablePromise<LocationsCreateLocationResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/locations/',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Location
* Get location by ID.
* @param data The data for the request.
* @param data.id
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static readLocation(data: LocationsReadLocationData): CancelablePromise<LocationsReadLocationResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Update Location
* Update a location.
* @param data The data for the request.
* @param data.id
* @param data.requestBody
* @returns LocationPublic Successful Response
* @throws ApiError
*/
public static updateLocation(data: LocationsUpdateLocationData): CancelablePromise<LocationsUpdateLocationResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete Location
* Delete a location.
* @param data The data for the request.
* @param data.id
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteLocation(data: LocationsDeleteLocationData): CancelablePromise<LocationsDeleteLocationResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/locations/{id}',
path: {
id: data.id
},
errors: {
422: 'Validation Error'
}
});
}
}
export class LoginService { export class LoginService {
/** /**
* Login Access Token * Login Access Token

View File

@@ -36,6 +36,29 @@ export type ItemUpdate = {
description?: (string | null); description?: (string | null);
}; };
export type LocationCreate = {
title: string;
description?: (string | null);
};
export type LocationPublic = {
title: string;
description?: (string | null);
id: string;
owner_id: string;
created_at?: (string | null);
};
export type LocationsPublic = {
data: Array<LocationPublic>;
count: number;
};
export type LocationUpdate = {
title?: (string | null);
description?: (string | null);
};
export type Message = { export type Message = {
message: string; message: string;
}; };
@@ -145,6 +168,38 @@ export type ItemsDeleteItemData = {
export type ItemsDeleteItemResponse = (Message); export type ItemsDeleteItemResponse = (Message);
export type LocationsReadLocationsData = {
limit?: number;
skip?: number;
};
export type LocationsReadLocationsResponse = (LocationsPublic);
export type LocationsCreateLocationData = {
requestBody: LocationCreate;
};
export type LocationsCreateLocationResponse = (LocationPublic);
export type LocationsReadLocationData = {
id: string;
};
export type LocationsReadLocationResponse = (LocationPublic);
export type LocationsUpdateLocationData = {
id: string;
requestBody: LocationUpdate;
};
export type LocationsUpdateLocationResponse = (LocationPublic);
export type LocationsDeleteLocationData = {
id: string;
};
export type LocationsDeleteLocationResponse = (Message);
export type LoginLoginAccessTokenData = { export type LoginLoginAccessTokenData = {
formData: Body_login_login_access_token; formData: Body_login_login_access_token;
}; };

View File

@@ -4,13 +4,13 @@ import { FaXTwitter } from "react-icons/fa6"
const socialLinks = [ const socialLinks = [
{ {
icon: FaGithub, icon: FaGithub,
href: "https://github.com/fastapi/fastapi", href: "https://makefire.fun",
label: "GitHub", label: "GitHub",
}, },
{ icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" }, { icon: FaXTwitter, href: "https://makefire.fun", label: "X" },
{ {
icon: FaLinkedinIn, icon: FaLinkedinIn,
href: "https://linkedin.com/company/fastapi", href: "https://makefire.fun",
label: "LinkedIn", label: "LinkedIn",
}, },
] ]
@@ -22,7 +22,7 @@ export function Footer() {
<footer className="border-t py-4 px-6"> <footer className="border-t py-4 px-6">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row"> <div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Full Stack FastAPI Template - {currentYear} SpatialHub - {currentYear}
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{socialLinks.map(({ icon: Icon, href, label }) => ( {socialLinks.map(({ icon: Icon, href, label }) => (

View File

@@ -2,10 +2,10 @@ import { Link } from "@tanstack/react-router"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import icon from "/assets/images/fastapi-icon.svg" import icon from "/assets/images/spatialhub-icon.svg"
import iconLight from "/assets/images/fastapi-icon-light.svg" import iconLight from "/assets/images/spatialhub-icon-light.svg"
import logo from "/assets/images/fastapi-logo.svg" import logo from "/assets/images/spatialhub-logo.svg"
import logoLight from "/assets/images/fastapi-logo-light.svg" import logoLight from "/assets/images/spatialhub-logo-light.svg"
interface LogoProps { interface LogoProps {
variant?: "full" | "icon" | "responsive" variant?: "full" | "icon" | "responsive"
@@ -29,7 +29,7 @@ export function Logo({
<> <>
<img <img
src={fullLogo} src={fullLogo}
alt="FastAPI" alt="SpatialHub"
className={cn( className={cn(
"h-6 w-auto group-data-[collapsible=icon]:hidden", "h-6 w-auto group-data-[collapsible=icon]:hidden",
className, className,
@@ -37,7 +37,7 @@ export function Logo({
/> />
<img <img
src={iconLogo} src={iconLogo}
alt="FastAPI" alt="SpatialHub"
className={cn( className={cn(
"size-5 hidden group-data-[collapsible=icon]:block", "size-5 hidden group-data-[collapsible=icon]:block",
className, className,
@@ -47,7 +47,7 @@ export function Logo({
) : ( ) : (
<img <img
src={variant === "full" ? fullLogo : iconLogo} src={variant === "full" ? fullLogo : iconLogo}
alt="FastAPI" alt="SpatialHub"
className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)} className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)}
/> />
) )

View File

@@ -5,7 +5,7 @@ import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { type ItemCreate, ItemsService } from "@/client" import { type LocationCreate, LocationsService } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -37,7 +37,7 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema> type FormData = z.infer<typeof formSchema>
const AddItem = () => { const AddLocation = () => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
@@ -53,16 +53,16 @@ const AddItem = () => {
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: ItemCreate) => mutationFn: (data: LocationCreate) =>
ItemsService.createItem({ requestBody: data }), LocationsService.createLocation({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Item created successfully") showSuccessToast("Location created successfully")
form.reset() form.reset()
setIsOpen(false) setIsOpen(false)
}, },
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["locations"] })
}, },
}) })
@@ -75,14 +75,14 @@ const AddItem = () => {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="my-4"> <Button className="my-4">
<Plus className="mr-2" /> <Plus className="mr-2" />
Add Item Add Location
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Item</DialogTitle> <DialogTitle>Add Location</DialogTitle>
<DialogDescription> <DialogDescription>
Fill in the details to add a new item. Fill in the details to add a new location.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
@@ -141,4 +141,4 @@ const AddItem = () => {
) )
} }
export default AddItem export default AddLocation

View File

@@ -3,7 +3,7 @@ import { Trash2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { ItemsService } from "@/client" import { LocationsService } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -19,25 +19,25 @@ import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils" import { handleError } from "@/utils"
interface DeleteItemProps { interface DeleteLocationProps {
id: string id: string
onSuccess: () => void onSuccess: () => void
} }
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => { const DeleteLocation = ({ id, onSuccess }: DeleteLocationProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { handleSubmit } = useForm() const { handleSubmit } = useForm()
const deleteItem = async (id: string) => { const deleteLocation = async (id: string) => {
await ItemsService.deleteItem({ id: id }) await LocationsService.deleteLocation({ id: id })
} }
const mutation = useMutation({ const mutation = useMutation({
mutationFn: deleteItem, mutationFn: deleteLocation,
onSuccess: () => { onSuccess: () => {
showSuccessToast("The item was deleted successfully") showSuccessToast("The location was deleted successfully")
setIsOpen(false) setIsOpen(false)
onSuccess() onSuccess()
}, },
@@ -59,14 +59,14 @@ const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<Trash2 /> <Trash2 />
Delete Item Delete Location
</DropdownMenuItem> </DropdownMenuItem>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Item</DialogTitle> <DialogTitle>Delete Location</DialogTitle>
<DialogDescription> <DialogDescription>
This item will be permanently deleted. Are you sure? You will not This location will be permanently deleted. Are you sure? You will not
be able to undo this action. be able to undo this action.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -91,4 +91,4 @@ const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
) )
} }
export default DeleteItem export default DeleteLocation

View File

@@ -5,7 +5,7 @@ import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { type ItemPublic, ItemsService } from "@/client" import { type LocationPublic, LocationsService } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -37,12 +37,12 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema> type FormData = z.infer<typeof formSchema>
interface EditItemProps { interface EditLocationProps {
item: ItemPublic location: LocationPublic
onSuccess: () => void onSuccess: () => void
} }
const EditItem = ({ item, onSuccess }: EditItemProps) => { const EditLocation = ({ location, onSuccess }: EditLocationProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
@@ -52,22 +52,22 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
title: item.title, title: location.title,
description: item.description ?? undefined, description: location.description ?? undefined,
}, },
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: FormData) => mutationFn: (data: FormData) =>
ItemsService.updateItem({ id: item.id, requestBody: data }), LocationsService.updateLocation({ id: location.id, requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Item updated successfully") showSuccessToast("Location updated successfully")
setIsOpen(false) setIsOpen(false)
onSuccess() onSuccess()
}, },
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["locations"] })
}, },
}) })
@@ -82,15 +82,15 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<Pencil /> <Pencil />
Edit Item Edit Location
</DropdownMenuItem> </DropdownMenuItem>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Item</DialogTitle> <DialogTitle>Edit Location</DialogTitle>
<DialogDescription> <DialogDescription>
Update the item details below. Update the location details below.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
@@ -142,4 +142,4 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => {
) )
} }
export default EditItem export default EditLocation

View File

@@ -1,21 +1,21 @@
import { EllipsisVertical } from "lucide-react" import { EllipsisVertical } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import type { ItemPublic } from "@/client" import type { LocationPublic } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import DeleteItem from "../Items/DeleteItem" import DeleteLocation from "../Locations/DeleteLocation"
import EditItem from "../Items/EditItem" import EditLocation from "../Locations/EditLocation"
interface ItemActionsMenuProps { interface LocationActionsMenuProps {
item: ItemPublic location: LocationPublic
} }
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { export const LocationActionsMenu = ({ location }: LocationActionsMenuProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@@ -26,8 +26,8 @@ export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<EditItem item={item} onSuccess={() => setOpen(false)} /> <EditLocation location={location} onSuccess={() => setOpen(false)} />
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} /> <DeleteLocation id={location.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )

View File

@@ -1,11 +1,11 @@
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { Check, Copy } from "lucide-react" import { Check, Copy } from "lucide-react"
import type { ItemPublic } from "@/client" import type { LocationPublic } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard" import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ItemActionsMenu } from "./ItemActionsMenu" import { LocationActionsMenu } from "./LocationActionsMenu"
function CopyId({ id }: { id: string }) { function CopyId({ id }: { id: string }) {
const [copiedText, copy] = useCopyToClipboard() const [copiedText, copy] = useCopyToClipboard()
@@ -31,7 +31,7 @@ function CopyId({ id }: { id: string }) {
) )
} }
export const columns: ColumnDef<ItemPublic>[] = [ export const columns: ColumnDef<LocationPublic>[] = [
{ {
accessorKey: "id", accessorKey: "id",
header: "ID", header: "ID",
@@ -66,7 +66,7 @@ export const columns: ColumnDef<ItemPublic>[] = [
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end"> <div className="flex justify-end">
<ItemActionsMenu item={row.original} /> <LocationActionsMenu location={row.original} />
</div> </div>
), ),
}, },

View File

@@ -8,7 +8,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
const PendingItems = () => ( const PendingSkeleton = () => (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -43,4 +43,4 @@ const PendingItems = () => (
</Table> </Table>
) )
export default PendingItems export default PendingSkeleton

View File

@@ -1,4 +1,4 @@
import { Briefcase, Home, Users } from "lucide-react" import { Home, MapPin, Users } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance" import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo" import { Logo } from "@/components/Common/Logo"
@@ -14,7 +14,7 @@ import { User } from "./User"
const baseItems: Item[] = [ const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" }, { icon: Home, title: "Dashboard", path: "/" },
{ icon: Briefcase, title: "Items", path: "/items" }, { icon: MapPin, title: "Locations", path: "/locations" },
] ]
export function AppSidebar() { export function AppSidebar() {

View File

@@ -16,7 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutItemsRouteImport } from './routes/_layout/items' import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({ const SignupRoute = SignupRouteImport.update({
@@ -53,9 +53,9 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutItemsRoute = LayoutItemsRouteImport.update({ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
id: '/items', id: '/locations',
path: '/items', path: '/locations',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({ const LayoutAdminRoute = LayoutAdminRouteImport.update({
@@ -65,14 +65,14 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof LayoutIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/recover-password': typeof RecoverPasswordRoute '/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/items': typeof LayoutItemsRoute '/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -80,7 +80,7 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/items': typeof LayoutItemsRoute '/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute '/': typeof LayoutIndexRoute
} }
@@ -92,21 +92,21 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute '/_layout/admin': typeof LayoutAdminRoute
'/_layout/items': typeof LayoutItemsRoute '/_layout/locations': typeof LayoutLocationsRoute
'/_layout/settings': typeof LayoutSettingsRoute '/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute '/_layout/': typeof LayoutIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/'
| '/login' | '/login'
| '/recover-password' | '/recover-password'
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/items' | '/locations'
| '/settings' | '/settings'
| '/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -114,7 +114,7 @@ export interface FileRouteTypes {
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/items' | '/locations'
| '/settings' | '/settings'
| '/' | '/'
id: id:
@@ -125,7 +125,7 @@ export interface FileRouteTypes {
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/_layout/admin' | '/_layout/admin'
| '/_layout/items' | '/_layout/locations'
| '/_layout/settings' | '/_layout/settings'
| '/_layout/' | '/_layout/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -171,7 +171,7 @@ declare module '@tanstack/react-router' {
'/_layout': { '/_layout': {
id: '/_layout' id: '/_layout'
path: '' path: ''
fullPath: '' fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
@@ -189,11 +189,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutSettingsRouteImport preLoaderRoute: typeof LayoutSettingsRouteImport
parentRoute: typeof LayoutRoute parentRoute: typeof LayoutRoute
} }
'/_layout/items': { '/_layout/locations': {
id: '/_layout/items' id: '/_layout/locations'
path: '/items' path: '/locations'
fullPath: '/items' fullPath: '/locations'
preLoaderRoute: typeof LayoutItemsRouteImport preLoaderRoute: typeof LayoutLocationsRouteImport
parentRoute: typeof LayoutRoute parentRoute: typeof LayoutRoute
} }
'/_layout/admin': { '/_layout/admin': {
@@ -208,14 +208,14 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren { interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute LayoutAdminRoute: typeof LayoutAdminRoute
LayoutItemsRoute: typeof LayoutItemsRoute LayoutLocationsRoute: typeof LayoutLocationsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute LayoutIndexRoute: typeof LayoutIndexRoute
} }
const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute, LayoutAdminRoute: LayoutAdminRoute,
LayoutItemsRoute: LayoutItemsRoute, LayoutLocationsRoute: LayoutLocationsRoute,
LayoutSettingsRoute: LayoutSettingsRoute, LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute, LayoutIndexRoute: LayoutIndexRoute,
} }

View File

@@ -1,69 +0,0 @@
import { useSuspenseQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Suspense } from "react"
import { ItemsService } from "@/client"
import { DataTable } from "@/components/Common/DataTable"
import AddItem from "@/components/Items/AddItem"
import { columns } from "@/components/Items/columns"
import PendingItems from "@/components/Pending/PendingItems"
function getItemsQueryOptions() {
return {
queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }),
queryKey: ["items"],
}
}
export const Route = createFileRoute("/_layout/items")({
component: Items,
head: () => ({
meta: [
{
title: "Items - FastAPI Template",
},
],
}),
})
function ItemsTableContent() {
const { data: items } = useSuspenseQuery(getItemsQueryOptions())
if (items.data.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-center py-12">
<div className="rounded-full bg-muted p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">You don't have any items yet</h3>
<p className="text-muted-foreground">Add a new item to get started</p>
</div>
)
}
return <DataTable columns={columns} data={items.data} />
}
function ItemsTable() {
return (
<Suspense fallback={<PendingItems />}>
<ItemsTableContent />
</Suspense>
)
}
function Items() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Items</h1>
<p className="text-muted-foreground">Create and manage your items</p>
</div>
<AddItem />
</div>
<ItemsTable />
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { useSuspenseQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { Suspense } from "react"
import { LocationsService } from "@/client"
import { DataTable } from "@/components/Common/DataTable"
import AddLocation from "@/components/Locations/AddLocation"
import { columns } from "@/components/Locations/columns"
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
function getLocationsQueryOptions() {
return {
queryFn: () => LocationsService.readLocations({ skip: 0, limit: 100 }),
queryKey: ["locations"],
}
}
export const Route = createFileRoute("/_layout/locations")({
component: Locations,
head: () => ({
meta: [
{
title: "Locations - FastAPI Template",
},
],
}),
})
function LocationsTableContent() {
const { data: locations } = useSuspenseQuery(getLocationsQueryOptions())
if (locations.data.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-center py-12">
<div className="rounded-full bg-muted p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">You don't have any locations yet</h3>
<p className="text-muted-foreground">Add a new location to get started</p>
</div>
)
}
return <DataTable columns={columns} data={locations.data} />
}
function LocationsTable() {
return (
<Suspense fallback={<PendingSkeleton />}>
<LocationsTableContent />
</Suspense>
)
}
function Locations() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Locations</h1>
<p className="text-muted-foreground">Create and manage your locations</p>
</div>
<AddLocation />
</div>
<LocationsTable />
</div>
)
}

View File

@@ -1,132 +0,0 @@
import { expect, test } from "@playwright/test"
import { createUser } from "./utils/privateApi"
import {
randomEmail,
randomItemDescription,
randomItemTitle,
randomPassword,
} from "./utils/random"
import { logInUser } from "./utils/user"
test("Items page is accessible and shows correct title", async ({ page }) => {
await page.goto("/items")
await expect(page.getByRole("heading", { name: "Items" })).toBeVisible()
await expect(page.getByText("Create and manage your items")).toBeVisible()
})
test("Add Item button is visible", async ({ page }) => {
await page.goto("/items")
await expect(page.getByRole("button", { name: "Add Item" })).toBeVisible()
})
test.describe("Items management", () => {
test.use({ storageState: { cookies: [], origins: [] } })
let email: string
const password = randomPassword()
test.beforeAll(async () => {
email = randomEmail()
await createUser({ email, password })
})
test.beforeEach(async ({ page }) => {
await logInUser(page, email, password)
await page.goto("/items")
})
test("Create a new item successfully", async ({ page }) => {
const title = randomItemTitle()
const description = randomItemDescription()
await page.getByRole("button", { name: "Add Item" }).click()
await page.getByLabel("Title").fill(title)
await page.getByLabel("Description").fill(description)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Item created successfully")).toBeVisible()
await expect(page.getByText(title)).toBeVisible()
})
test("Create item with only required fields", async ({ page }) => {
const title = randomItemTitle()
await page.getByRole("button", { name: "Add Item" }).click()
await page.getByLabel("Title").fill(title)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Item created successfully")).toBeVisible()
await expect(page.getByText(title)).toBeVisible()
})
test("Cancel item creation", async ({ page }) => {
await page.getByRole("button", { name: "Add Item" }).click()
await page.getByLabel("Title").fill("Test Item")
await page.getByRole("button", { name: "Cancel" }).click()
await expect(page.getByRole("dialog")).not.toBeVisible()
})
test("Title is required", async ({ page }) => {
await page.getByRole("button", { name: "Add Item" }).click()
await page.getByLabel("Title").fill("")
await page.getByLabel("Title").blur()
await expect(page.getByText("Title is required")).toBeVisible()
})
test.describe("Edit and Delete", () => {
let itemTitle: string
test.beforeEach(async ({ page }) => {
itemTitle = randomItemTitle()
await page.getByRole("button", { name: "Add Item" }).click()
await page.getByLabel("Title").fill(itemTitle)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Item created successfully")).toBeVisible()
await expect(page.getByRole("dialog")).not.toBeVisible()
})
test("Edit an item successfully", async ({ page }) => {
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
await itemRow.getByRole("button").last().click()
await page.getByRole("menuitem", { name: "Edit Item" }).click()
const updatedTitle = randomItemTitle()
await page.getByLabel("Title").fill(updatedTitle)
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Item updated successfully")).toBeVisible()
await expect(page.getByText(updatedTitle)).toBeVisible()
})
test("Delete an item successfully", async ({ page }) => {
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
await itemRow.getByRole("button").last().click()
await page.getByRole("menuitem", { name: "Delete Item" }).click()
await page.getByRole("button", { name: "Delete" }).click()
await expect(
page.getByText("The item was deleted successfully"),
).toBeVisible()
await expect(page.getByText(itemTitle)).not.toBeVisible()
})
})
})
test.describe("Items empty state", () => {
test.use({ storageState: { cookies: [], origins: [] } })
test("Shows empty state message when no items exist", async ({ page }) => {
const email = randomEmail()
const password = randomPassword()
await createUser({ email, password })
await logInUser(page, email, password)
await page.goto("/items")
await expect(page.getByText("You don't have any items yet")).toBeVisible()
await expect(page.getByText("Add a new item to get started")).toBeVisible()
})
})