✨ Trip: packing list
This commit is contained in:
parent
d8cb828963
commit
3cfd33ce94
@ -0,0 +1,45 @@
|
||||
"""Trip Packing list
|
||||
|
||||
Revision ID: 1181ac441ce5
|
||||
Revises: 77027ac49c26
|
||||
Create Date: 2025-08-16 11:35:34.870999
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1181ac441ce5"
|
||||
down_revision = "77027ac49c26"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"trippackinglistitem",
|
||||
sa.Column("text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("qt", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.Enum("CLOTHES", "TOILETRIES", "TECH", "DOCUMENTS", "OTHER", name="packinglistcategoryenum"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("packed", sa.Boolean(), nullable=True),
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("trip_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["trip_id"], ["trip.id"], name=op.f("fk_trippackinglistitem_trip_id_trip"), ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user"], ["user.username"], name=op.f("fk_trippackinglistitem_user_user"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_trippackinglistitem")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("trippackinglistitem")
|
||||
@ -39,6 +39,14 @@ class TripItemStatusEnum(str, Enum):
|
||||
OPTIONAL = "optional"
|
||||
|
||||
|
||||
class PackingListCategoryEnum(str, Enum):
|
||||
CLOTHES = "clothes"
|
||||
TOILETRIES = "toiletries"
|
||||
TECH = "tech"
|
||||
DOCUMENTS = "documents"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class TripShareURL(BaseModel):
|
||||
url: str
|
||||
|
||||
@ -251,6 +259,7 @@ class Trip(TripBase, table=True):
|
||||
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
|
||||
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
|
||||
|
||||
class TripCreate(TripBase):
|
||||
@ -401,3 +410,39 @@ class TripShare(SQLModel, table=True):
|
||||
|
||||
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||
trip: Trip | None = Relationship(back_populates="shares")
|
||||
|
||||
|
||||
class TripPackingListItemBase(SQLModel):
|
||||
text: str | None = None
|
||||
qt: int | None = None
|
||||
category: PackingListCategoryEnum | None = None
|
||||
packed: bool | None = None
|
||||
|
||||
|
||||
class TripPackingListItem(TripPackingListItemBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||
|
||||
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||
trip: Trip | None = Relationship(back_populates="packing_items")
|
||||
|
||||
|
||||
class TripPackingListItemCreate(TripPackingListItemBase):
|
||||
packed: bool = False
|
||||
|
||||
|
||||
class TripPackingListItemUpdate(TripPackingListItemBase): ...
|
||||
|
||||
|
||||
class TripPackingListItemRead(TripPackingListItemBase):
|
||||
id: int
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, obj: "TripPackingListItem") -> "TripPackingListItemRead":
|
||||
return cls(
|
||||
id=obj.id,
|
||||
text=obj.text,
|
||||
qt=obj.qt,
|
||||
category=obj.category,
|
||||
packed=obj.packed,
|
||||
)
|
||||
|
||||
@ -8,8 +8,11 @@ from ..deps import SessionDep, get_current_username
|
||||
from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
|
||||
TripDayBase, TripDayRead, TripItem,
|
||||
TripItemCreate, TripItemRead, TripItemUpdate,
|
||||
TripPlaceLink, TripRead, TripReadBase, TripShare,
|
||||
TripShareURL, TripUpdate)
|
||||
TripPackingListItem, TripPackingListItemCreate,
|
||||
TripPackingListItemRead,
|
||||
TripPackingListItemUpdate, TripPlaceLink,
|
||||
TripRead, TripReadBase, TripShare, TripShareURL,
|
||||
TripUpdate)
|
||||
from ..security import verify_exists_and_owns
|
||||
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
|
||||
save_image_to_file)
|
||||
@ -407,3 +410,88 @@ def delete_shared_trip(
|
||||
session.delete(db_share)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/{trip_id}/packing", response_model=list[TripPackingListItemRead])
|
||||
def read_packing_list(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> list[TripPackingListItemRead]:
|
||||
p_items = session.exec(
|
||||
select(TripPackingListItem)
|
||||
.where(TripPackingListItem.trip_id == trip_id, TripPackingListItem.user == current_user)
|
||||
.order_by(TripPackingListItem.id.asc())
|
||||
).all()
|
||||
|
||||
return [TripPackingListItemRead.serialize(i) for i in p_items]
|
||||
|
||||
|
||||
@router.post("/{trip_id}/packing", response_model=TripPackingListItemRead)
|
||||
def create_packing_item(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
data: TripPackingListItemCreate,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripPackingListItemRead:
|
||||
item = TripPackingListItem(
|
||||
**data.model_dump(),
|
||||
trip_id=trip_id,
|
||||
user=current_user,
|
||||
)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return TripPackingListItemRead.serialize(item)
|
||||
|
||||
|
||||
@router.put("/{trip_id}/packing/{p_id}", response_model=TripPackingListItemRead)
|
||||
def update_packing_item(
|
||||
session: SessionDep,
|
||||
p_item: TripPackingListItemUpdate,
|
||||
trip_id: int,
|
||||
p_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripPackingListItemRead:
|
||||
db_item = session.exec(
|
||||
select(TripPackingListItem).where(
|
||||
TripPackingListItem.id == p_id,
|
||||
TripPackingListItem.trip_id == trip_id,
|
||||
TripPackingListItem.user == current_user,
|
||||
)
|
||||
).one_or_none()
|
||||
|
||||
if not db_item:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
item_data = p_item.model_dump(exclude_unset=True)
|
||||
for key, value in item_data.items():
|
||||
setattr(db_item, key, value)
|
||||
|
||||
session.add(db_item)
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
return TripPackingListItemRead.serialize(db_item)
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/packing/{p_id}")
|
||||
def delete_packing_item(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
p_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
item = session.exec(
|
||||
select(TripPackingListItem).where(
|
||||
TripPackingListItem.id == p_id,
|
||||
TripPackingListItem.trip_id == trip_id,
|
||||
TripPackingListItem.user == current_user,
|
||||
)
|
||||
).one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
<p-button pTooltip="Delete Trip" text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" />
|
||||
<p-button pTooltip="Edit Trip" text (click)="editTrip()" icon="pi pi-pencil" />
|
||||
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||
<p-button pTooltip="Packing list" tooltipPosition="left" text (click)="openPackingList()" icon="pi pi-briefcase"
|
||||
severity="help" />
|
||||
</div>
|
||||
|
||||
<div class="flex md:hidden">
|
||||
@ -551,3 +553,38 @@
|
||||
</ng-container>
|
||||
}
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog header="Packing list" [draggable]="false" [dismissableMask]="true" [modal]="true"
|
||||
[(visible)]="packingDialogVisible" styleClass="w-[95%] md:w-[70%] lg:w-[50%]">
|
||||
<section class="md:max-w-3/4 md:mx-auto max-h-[80%] md:max-h-[600px]">
|
||||
<div class="flex justify-center">
|
||||
<p-button (click)="addPackingItem()" icon="pi pi-plus" label="Add item" text />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 mt-4 pb-4">
|
||||
@for (c of dispPackingList | keyvalue; track c.key) {
|
||||
<div class="mt-4 text-md font-semibold capitalize">{{ c.key }}</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
@for (item of c.value; track item.id) {
|
||||
<div class="relative group flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5">
|
||||
<label [for]="item.id" [class.line-through]="item.packed"
|
||||
class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<p-checkbox (onChange)="onCheckPackingItem($event, item.id)" [binary]="true" [inputId]="item.id.toString()"
|
||||
[(ngModel)]="item.packed" />
|
||||
<div class="pr-6 md:pr-0 truncate select-none flex-1">
|
||||
@if (item.qt) {<span class="text-gray-400 mr-0.5">{{ item.qt }}</span>}
|
||||
<span>{{ item.text }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div
|
||||
class="md:opacity-0 absolute right-0 top-1/2 -translate-y-1/2 md:group-hover:opacity-100 bg-white md:bg-gray-100 rounded">
|
||||
<p-button size="small" text icon="pi pi-trash" (click)="deletePackingItem(item)" severity="danger" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</p-dialog>
|
||||
@ -14,6 +14,7 @@ import {
|
||||
TripDay,
|
||||
TripItem,
|
||||
TripStatus,
|
||||
PackingItem,
|
||||
} from "../../types/trip";
|
||||
import { Place } from "../../types/poi";
|
||||
import {
|
||||
@ -52,6 +53,8 @@ import { ClipboardModule } from "@angular/cdk/clipboard";
|
||||
import { TooltipModule } from "primeng/tooltip";
|
||||
import { MultiSelectModule } from "primeng/multiselect";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox";
|
||||
import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-trip",
|
||||
@ -73,6 +76,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
TooltipModule,
|
||||
ClipboardModule,
|
||||
MultiSelectModule,
|
||||
CheckboxModule,
|
||||
],
|
||||
templateUrl: "./trip.component.html",
|
||||
styleUrls: ["./trip.component.scss"],
|
||||
@ -93,8 +97,11 @@ export class TripComponent implements AfterViewInit {
|
||||
collapsedTripPlaces = false;
|
||||
collapsedTripStatuses = false;
|
||||
shareDialogVisible = false;
|
||||
packingDialogVisible = false;
|
||||
isExpanded = false;
|
||||
isFilteringMode = false;
|
||||
packingList: PackingItem[] = [];
|
||||
dispPackingList: Record<string, PackingItem[]> = {};
|
||||
|
||||
map?: L.Map;
|
||||
markerClusterGroup?: L.MarkerClusterGroup;
|
||||
@ -107,6 +114,14 @@ export class TripComponent implements AfterViewInit {
|
||||
{
|
||||
label: "Actions",
|
||||
items: [
|
||||
{
|
||||
label: "Packing",
|
||||
icon: "pi pi-briefcase",
|
||||
iconClass: "text-purple-500!",
|
||||
command: () => {
|
||||
this.openPackingList();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "pi pi-pencil",
|
||||
@ -1315,4 +1330,117 @@ export class TripComponent implements AfterViewInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openPackingList() {
|
||||
if (!this.trip) return;
|
||||
|
||||
if (!this.packingList.length)
|
||||
this.apiService
|
||||
.getPackingList(this.trip.id)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (items) => {
|
||||
this.packingList = [...items];
|
||||
this.computeDispPackingList();
|
||||
},
|
||||
});
|
||||
this.packingDialogVisible = true;
|
||||
}
|
||||
|
||||
addPackingItem() {
|
||||
if (!this.trip) return;
|
||||
|
||||
const modal: DynamicDialogRef = this.dialogService.open(
|
||||
TripCreatePackingModalComponent,
|
||||
{
|
||||
header: "Create Packing",
|
||||
modal: true,
|
||||
appendTo: "body",
|
||||
closable: true,
|
||||
dismissableMask: true,
|
||||
width: "40vw",
|
||||
breakpoints: {
|
||||
"1260px": "70vw",
|
||||
"600px": "90vw",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (item: PackingItem | null) => {
|
||||
if (!item) return;
|
||||
|
||||
this.apiService
|
||||
.postPackingItem(this.trip!.id, item)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (item) => {
|
||||
this.packingList.push(item);
|
||||
this.computeDispPackingList();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onCheckPackingItem(e: CheckboxChangeEvent, id: number) {
|
||||
if (!this.trip) return;
|
||||
this.apiService
|
||||
.putPackingItem(this.trip.id, id, { packed: e.checked })
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (item) => {
|
||||
const i = this.packingList.find((p) => p.id == item.id);
|
||||
if (i) i.packed = item.packed;
|
||||
this.computeDispPackingList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deletePackingItem(item: PackingItem) {
|
||||
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||
header: "Confirm deletion",
|
||||
modal: true,
|
||||
closable: true,
|
||||
dismissableMask: true,
|
||||
breakpoints: {
|
||||
"640px": "90vw",
|
||||
},
|
||||
data: `Delete ${item.text.substring(0, 50)} ?`,
|
||||
});
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (bool) => {
|
||||
if (!bool) return;
|
||||
this.apiService
|
||||
.deletePackingItem(this.trip!.id, item.id)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
const index = this.packingList.findIndex((p) => p.id == item.id);
|
||||
if (index > -1) this.packingList.splice(index, 1);
|
||||
this.computeDispPackingList();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
computeDispPackingList() {
|
||||
const sorted: PackingItem[] = [...this.packingList].sort((a, b) =>
|
||||
a.packed !== b.packed
|
||||
? a.packed
|
||||
? 1
|
||||
: -1
|
||||
: a.text.localeCompare(b.text),
|
||||
);
|
||||
|
||||
this.dispPackingList = sorted.reduce<Record<string, PackingItem[]>>(
|
||||
(acc, item) => {
|
||||
(acc[item.category] ??= []).push(item);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
<div pFocusTrap class="grid items-center gap-4" [formGroup]="packingForm">
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
<p-inputnumber id="qt" formControlName="qt" mode="decimal" [maxFractionDigits]="0" min="0" fluid />
|
||||
<label for="qt">Qt.</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
<input class="col-span-2" id="text" formControlName="text" (keypress.enter)="closeDialog()" pInputText fluid />
|
||||
<label for="text">Text</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
<p-select [options]="packingCategories" id="category" appendTo="body" formControlName="category"
|
||||
optionLabel="dispValue" optionValue="value" placeholder="Select Category" fluid />
|
||||
<label for="category">Category</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<div class="mt-4 text-right">
|
||||
<p-button (click)="closeDialog()" [disabled]="!packingForm.dirty || !packingForm.valid">{{
|
||||
packingForm.get("id")?.value
|
||||
!== -1 ? "Update" : "Create" }}</p-button>
|
||||
</div>
|
||||
@ -0,0 +1,66 @@
|
||||
import { Component } from "@angular/core";
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||
import { FloatLabelModule } from "primeng/floatlabel";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { FocusTrapModule } from "primeng/focustrap";
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { InputNumberModule } from "primeng/inputnumber";
|
||||
|
||||
@Component({
|
||||
selector: "app-trip-create-packing-modal",
|
||||
imports: [
|
||||
FloatLabelModule,
|
||||
InputTextModule,
|
||||
ButtonModule,
|
||||
ReactiveFormsModule,
|
||||
FocusTrapModule,
|
||||
SelectModule,
|
||||
InputNumberModule,
|
||||
],
|
||||
standalone: true,
|
||||
templateUrl: "./trip-create-packing-modal.component.html",
|
||||
styleUrl: "./trip-create-packing-modal.component.scss",
|
||||
})
|
||||
export class TripCreatePackingModalComponent {
|
||||
packingForm: FormGroup;
|
||||
readonly packingCategories = [
|
||||
{ value: "clothes", dispValue: "Clothes" },
|
||||
{ value: "toiletries", dispValue: "Toiletries" },
|
||||
{ value: "tech", dispValue: "Tech" },
|
||||
{ value: "documents", dispValue: "Documents" },
|
||||
{ value: "other", dispValue: "Other" },
|
||||
];
|
||||
|
||||
constructor(
|
||||
private ref: DynamicDialogRef,
|
||||
private fb: FormBuilder,
|
||||
private config: DynamicDialogConfig,
|
||||
) {
|
||||
this.packingForm = this.fb.group({
|
||||
id: -1,
|
||||
qt: null,
|
||||
text: ["", { validators: Validators.required }],
|
||||
category: ["", { validators: Validators.required }],
|
||||
});
|
||||
|
||||
const patchValue = this.config.data?.packing;
|
||||
if (patchValue) {
|
||||
this.packingForm.patchValue(patchValue);
|
||||
}
|
||||
}
|
||||
|
||||
closeDialog() {
|
||||
if (!this.packingForm.valid) return;
|
||||
|
||||
// Normalize data for API POST
|
||||
let ret = this.packingForm.value;
|
||||
this.ref.close(ret);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
|
||||
import { Info } from "../types/info";
|
||||
import { ImportResponse, Settings } from "../types/settings";
|
||||
import {
|
||||
PackingItem,
|
||||
SharedTripURL,
|
||||
Trip,
|
||||
TripBase,
|
||||
@ -232,6 +233,39 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
getPackingList(trip_id: number): Observable<PackingItem[]> {
|
||||
return this.httpClient.get<PackingItem[]>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
|
||||
);
|
||||
}
|
||||
|
||||
postPackingItem(
|
||||
trip_id: number,
|
||||
p_item: PackingItem,
|
||||
): Observable<PackingItem> {
|
||||
return this.httpClient.post<PackingItem>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
|
||||
p_item,
|
||||
);
|
||||
}
|
||||
|
||||
putPackingItem(
|
||||
trip_id: number,
|
||||
p_id: number,
|
||||
p_item: Partial<PackingItem>,
|
||||
): Observable<PackingItem> {
|
||||
return this.httpClient.put<PackingItem>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
|
||||
p_item,
|
||||
);
|
||||
}
|
||||
|
||||
deletePackingItem(trip_id: number, p_id: number): Observable<null> {
|
||||
return this.httpClient.delete<null>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
checkVersion(): Observable<string> {
|
||||
return this.httpClient.get<string>(
|
||||
`${this.apiBaseUrl}/settings/checkversion`,
|
||||
|
||||
@ -65,3 +65,11 @@ export interface FlattenedTripItem {
|
||||
export interface SharedTripURL {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PackingItem {
|
||||
id: number;
|
||||
text: string;
|
||||
category: string;
|
||||
qt?: number;
|
||||
packed?: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user