Compare commits
4 commits
c2773d775b
...
34b12f6a44
Author | SHA1 | Date | |
---|---|---|---|
34b12f6a44 | |||
4f5f2be68f | |||
f0eff338ff | |||
b59aeeb5e5 |
38 changed files with 5228 additions and 3813 deletions
|
@ -0,0 +1,50 @@
|
|||
"""Add firmware_update table
|
||||
|
||||
Revision ID: 00edfb13e611
|
||||
Revises: c7f72154c90b
|
||||
Create Date: 2025-03-23 14:01:14.029527+00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '00edfb13e611'
|
||||
down_revision: Union[str, None] = 'c7f72154c90b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('firmware_updates',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('request_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.Enum('CREATED', 'SUBMITTED', 'DOWNLOADED', 'DOWNLOAD_FAILED', 'DOWNLOADING', 'DOWNLOAD_SCHEDULED', 'DOWNLOAD_PAUSED', 'IDLE', 'INSTALLATION_FAILED', 'INSTALLING', 'INSTALLED', 'INSTALL_REBOOTING', 'INSTALL_SCHEDULED', 'INSTALL_VERIFICATION_FAILED', 'INVALID_SIGNATURE', 'SIGNATURE_VERIFIED', name='firmwareupdatestatus'), nullable=True),
|
||||
sa.Column('retries', sa.Integer(), nullable=True),
|
||||
sa.Column('retry_interval', sa.Integer(), nullable=True),
|
||||
sa.Column('location', sa.String(), nullable=True),
|
||||
sa.Column('retrieve_date_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('install_date_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_firmware_updates_chargepoint_id'), 'firmware_updates', ['chargepoint_id'], unique=False)
|
||||
op.alter_column('users', 'is_active',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('users', 'is_active',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True)
|
||||
op.drop_index(op.f('ix_firmware_updates_chargepoint_id'), table_name='firmware_updates')
|
||||
op.drop_table('firmware_updates')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,32 @@
|
|||
"""Add signature fields to firmware_update table
|
||||
|
||||
Revision ID: 506cc8d086c9
|
||||
Revises: 00edfb13e611
|
||||
Create Date: 2025-03-23 14:49:42.662564+00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '506cc8d086c9'
|
||||
down_revision: Union[str, None] = '00edfb13e611'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('firmware_updates', sa.Column('signing_certificate', sa.Text(), nullable=True))
|
||||
op.add_column('firmware_updates', sa.Column('signature', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('firmware_updates', 'signature')
|
||||
op.drop_column('firmware_updates', 'signing_certificate')
|
||||
# ### end Alembic commands ###
|
|
@ -2,6 +2,7 @@ __all__ = [
|
|||
"chargepoint_variable",
|
||||
"chargepoint",
|
||||
"connector",
|
||||
"firmware_update",
|
||||
"id_token",
|
||||
"meter_value",
|
||||
"session",
|
||||
|
|
22
backend/app/models/firmware_update.py
Normal file
22
backend/app/models/firmware_update.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
|
||||
|
||||
from app.database import Base
|
||||
from app.schemas.firmware_update import FirmwareUpdateStatus
|
||||
|
||||
class FirmwareUpdate(Base):
|
||||
__tablename__ = "firmware_updates"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
request_id = Column(Integer)
|
||||
status = Column(Enum(FirmwareUpdateStatus))
|
||||
|
||||
retries = Column(Integer)
|
||||
retry_interval = Column(Integer)
|
||||
location = Column(String)
|
||||
retrieve_date_time = Column(DateTime)
|
||||
install_date_time = Column(DateTime, nullable=True)
|
||||
signing_certificate = Column(Text, nullable=True)
|
||||
signature = Column(String, nullable=True)
|
||||
|
||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
|
|
@ -8,7 +8,7 @@ class Session(Base):
|
|||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String)
|
||||
refresh_token = Column(String, unique=True, index=True)
|
||||
refresh_token = Column(String, nullable=False, unique=True, index=True)
|
||||
last_used = Column(DateTime(timezone=True))
|
||||
|
||||
user_id = Column(Uuid, ForeignKey("users.id"), index=True)
|
||||
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
|
|
@ -8,6 +8,7 @@ from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEvent
|
|||
from ocpp.v201.call import GetBaseReport
|
||||
|
||||
from app.services import (
|
||||
firmware_service,
|
||||
variable_service,
|
||||
id_token_service,
|
||||
chargepoint_service,
|
||||
|
@ -109,6 +110,11 @@ class ChargePoint(cp):
|
|||
else:
|
||||
return call_result.TransactionEvent(id_token_info=id_token_info)
|
||||
|
||||
@on(Action.firmware_status_notification)
|
||||
async def on_firmware_status_notification(self, status, request_id, **kwargs):
|
||||
await firmware_service.update_firmware_status(self.id, request_id, status)
|
||||
return call_result.FirmwareStatusNotification()
|
||||
|
||||
@on(Action.meter_values)
|
||||
async def on_meter_values(self, **kwargs):
|
||||
return call_result.MeterValues()
|
||||
|
|
|
@ -28,10 +28,13 @@ from app.schemas.chargepoint_variable import (
|
|||
MutabilityType,
|
||||
SetVariableStatusType
|
||||
)
|
||||
from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse
|
||||
from app.models.chargepoint import ChargePoint as DbChargePoint
|
||||
from app.models.user import User as DbUser
|
||||
from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable
|
||||
from app.models.firmware_update import FirmwareUpdate as DbFirmwareUpdate
|
||||
from app.security.jwt_bearer import JWTBearer
|
||||
from app.services import firmware_service
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/chargepoints",
|
||||
|
@ -293,3 +296,67 @@ async def update_chargepoint_variable(
|
|||
return ChargepointVariableResponse(status=status)
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
||||
|
||||
@router.get(path="/{chargepoint_id}/firmware-updates", response_model=list[FirmwareUpdate])
|
||||
async def get_firmware_updates(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
firmware_updates = db.query(DbFirmwareUpdate).filter(
|
||||
DbFirmwareUpdate.chargepoint_id == chargepoint_id
|
||||
).all()
|
||||
|
||||
return firmware_updates
|
||||
|
||||
@router.get(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}", response_model=FirmwareUpdate)
|
||||
async def get_firmware_update(
|
||||
chargepoint_id: UUID,
|
||||
firmware_update_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
firmware_update = db.query(DbFirmwareUpdate).filter(
|
||||
DbFirmwareUpdate.chargepoint_id == chargepoint_id,
|
||||
DbFirmwareUpdate.id == firmware_update_id
|
||||
).first()
|
||||
if firmware_update is None:
|
||||
raise HTTPException(status_code=404, detail="FirmwareUpdate not found")
|
||||
|
||||
return firmware_update
|
||||
|
||||
@router.post(path="/{chargepoint_id}/firmware-updates", response_model=FirmwareUpdate)
|
||||
async def create_firmware_update(
|
||||
chargepoint_id: UUID,
|
||||
firmware_update: FirmwareUpdateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
firmware_update = await firmware_service.create_firmware_update(chargepoint_id, firmware_update)
|
||||
return firmware_update
|
||||
|
||||
@router.post(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}/submit", response_model=ChargePointResetResponse)
|
||||
async def submit_firmware_update(
|
||||
chargepoint_id: UUID,
|
||||
firmware_update_id: UUID,
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
):
|
||||
if chargepoint_manager.is_connected(chargepoint_id) == False:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
||||
try:
|
||||
_, status = await firmware_service.submit_firmware_update(firmware_update_id)
|
||||
return FirmwareUpdateSubmissionResponse(status=status)
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
||||
|
|
43
backend/app/schemas/firmware_update.py
Normal file
43
backend/app/schemas/firmware_update.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
from datetime import datetime
|
||||
import enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
class FirmwareUpdateStatus(enum.Enum):
|
||||
CREATED = "xCreated"
|
||||
SUBMITTED = "xSubmitted"
|
||||
DOWNLOADED = "Downloaded"
|
||||
DOWNLOAD_FAILED = "DownloadFailed"
|
||||
DOWNLOADING = "Downloading"
|
||||
DOWNLOAD_SCHEDULED = "DownloadScheduled"
|
||||
DOWNLOAD_PAUSED = "DownloadPaused"
|
||||
IDLE = "Idle"
|
||||
INSTALLATION_FAILED = "InstallationFailed"
|
||||
INSTALLING = "Installing"
|
||||
INSTALLED = "Installed"
|
||||
INSTALL_REBOOTING = "InstallRebooting"
|
||||
INSTALL_SCHEDULED = "InstallScheduled"
|
||||
INSTALL_VERIFICATION_FAILED = "InstallVerificationFailed"
|
||||
INVALID_SIGNATURE = "InvalidSignature"
|
||||
SIGNATURE_VERIFIED = "SignatureVerified"
|
||||
|
||||
class FirmwareUpdateBase(BaseModel):
|
||||
retries: int
|
||||
retry_interval: int
|
||||
location: str
|
||||
retrieve_date_time: datetime
|
||||
install_date_time: Optional[datetime]
|
||||
signing_certificate: Optional[str]
|
||||
signature: Optional[str]
|
||||
|
||||
class FirmwareUpdate(FirmwareUpdateBase):
|
||||
id: UUID
|
||||
request_id: int
|
||||
status: FirmwareUpdateStatus
|
||||
|
||||
class FirmwareUpdateCreate(FirmwareUpdateBase):
|
||||
pass
|
||||
|
||||
class FirmwareUpdateSubmissionResponse(BaseModel):
|
||||
status: str
|
66
backend/app/services/firmware_service.py
Normal file
66
backend/app/services/firmware_service.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from uuid import UUID
|
||||
|
||||
from ocpp.v201.call import UpdateFirmware
|
||||
from ocpp.v201.call_result import UpdateFirmware as UpdateFirmwareResult
|
||||
from ocpp.v201.datatypes import FirmwareType
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.chargepoint import ChargePoint
|
||||
from app.models.firmware_update import FirmwareUpdate
|
||||
from app.ocpp_proto import chargepoint_manager
|
||||
from app.schemas.firmware_update import FirmwareUpdateCreate, FirmwareUpdateStatus
|
||||
|
||||
async def create_firmware_update(chargepoint_id: UUID, firmware_update: FirmwareUpdateCreate) -> FirmwareUpdate:
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.get(ChargePoint, chargepoint_id)
|
||||
latest_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).order_by(FirmwareUpdate.request_id.desc()).first()
|
||||
new_request_id = latest_firmware_update.request_id + 1 if latest_firmware_update else 1
|
||||
db_firmware_update = FirmwareUpdate(
|
||||
request_id=new_request_id,
|
||||
status=FirmwareUpdateStatus.CREATED,
|
||||
retries=firmware_update.retries,
|
||||
retry_interval=firmware_update.retry_interval,
|
||||
location=firmware_update.location,
|
||||
retrieve_date_time=firmware_update.retrieve_date_time,
|
||||
install_date_time=firmware_update.install_date_time,
|
||||
chargepoint_id=db_chargepoint.id,
|
||||
signing_certificate=firmware_update.signing_certificate,
|
||||
signature=firmware_update.signature
|
||||
)
|
||||
db.add(db_firmware_update)
|
||||
db.commit()
|
||||
db.refresh(db_firmware_update)
|
||||
return db_firmware_update
|
||||
|
||||
async def submit_firmware_update(firmware_update_id: UUID) -> tuple[FirmwareUpdate, str]:
|
||||
with SessionLocal() as db:
|
||||
db_firmware_update = db.get(FirmwareUpdate, firmware_update_id)
|
||||
try:
|
||||
result: UpdateFirmwareResult = await chargepoint_manager.call(
|
||||
db_firmware_update.chargepoint_id,
|
||||
payload=UpdateFirmware(
|
||||
request_id=db_firmware_update.request_id,
|
||||
retries=db_firmware_update.retries,
|
||||
retry_interval=db_firmware_update.retry_interval,
|
||||
firmware=FirmwareType(
|
||||
location=db_firmware_update.location,
|
||||
retrieve_date_time=db_firmware_update.retrieve_date_time.isoformat(),
|
||||
install_date_time=db_firmware_update.install_date_time.isoformat(),
|
||||
signing_certificate=db_firmware_update.signing_certificate,
|
||||
signature=db_firmware_update.signature
|
||||
)
|
||||
))
|
||||
if result.status == "Accepted" or result.status == "AcceptedCanceled":
|
||||
db_firmware_update.status = FirmwareUpdateStatus.SUBMITTED
|
||||
db.commit()
|
||||
|
||||
return db_firmware_update, result.status
|
||||
except TimeoutError as e:
|
||||
raise e
|
||||
|
||||
async def update_firmware_status(chargepoint_identity: str, request_id: int, status: FirmwareUpdateStatus):
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
db_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).filter(FirmwareUpdate.request_id == request_id).first()
|
||||
db_firmware_update.status = FirmwareUpdateStatus(status)
|
||||
db.commit()
|
388
frontend/package-lock.json
generated
388
frontend/package-lock.json
generated
|
@ -9,7 +9,14 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"axios": "^1.8.4",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"daisyui": "^5.0.3",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-chained-backend": "^4.6.2",
|
||||
"i18next-fetch-backend": "^6.0.0",
|
||||
"i18next-localstorage-backend": "^4.2.0",
|
||||
"tailwindcss": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -46,6 +53,18 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
|
||||
|
@ -1653,6 +1672,23 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
@ -1670,6 +1706,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
|
||||
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
|
@ -1694,6 +1746,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
@ -1767,6 +1832,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
@ -1856,6 +1933,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
|
||||
|
@ -1872,6 +1958,20 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||
|
@ -1885,6 +1985,51 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
|
||||
|
@ -2313,6 +2458,41 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
@ -2327,6 +2507,52 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
|
@ -2353,6 +2579,18 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
|
@ -2376,6 +2614,112 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "24.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
|
||||
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.4.tgz",
|
||||
"integrity": "sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-chained-backend": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-chained-backend/-/i18next-chained-backend-4.6.2.tgz",
|
||||
"integrity": "sha512-2P092fR+nAPQlGzPUoIIxbwo7PTBqQYgLxwv1XhSTQUAUoelLo5LkX+FqRxxSDg9WEAsrc8+2WL6mJtMGIa6WQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-fetch-backend": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-fetch-backend/-/i18next-fetch-backend-6.0.0.tgz",
|
||||
"integrity": "sha512-kVqnydqLVMZfVlOuP2nf71cREydlxEKLH43jUXAFdOku/GF+6b9fBg31anoos5XncdhdtiYgL9fheqMrtXRwng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-localstorage-backend": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-localstorage-backend/-/i18next-localstorage-backend-4.2.0.tgz",
|
||||
"integrity": "sha512-vglEQF0AnLriX7dLA2drHnqAYzHxnLwWQzBDw8YxcIDjOvYZz5rvpal59Dq4In+IHNmGNM32YgF0TDjBT0fHmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.15"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
@ -2836,6 +3180,15 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
@ -2873,6 +3226,27 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
@ -3215,6 +3589,12 @@
|
|||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
@ -3260,6 +3640,12 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
@ -3596,7 +3982,7 @@
|
|||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
@ -34,7 +34,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"axios": "^1.8.4",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"daisyui": "^5.0.3",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-chained-backend": "^4.6.2",
|
||||
"i18next-fetch-backend": "^6.0.0",
|
||||
"i18next-localstorage-backend": "^4.2.0",
|
||||
"tailwindcss": "^4.0.14"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,5 +13,17 @@
|
|||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false,
|
||||
"i18n-ally.enabledFrameworks": ["i18next", "svelte"],
|
||||
"i18n-ally.localesPaths": ["static/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.namespace": true,
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.refactor.templates": [
|
||||
{
|
||||
"templates": ["{{ t('{key}'{args}) }}"],
|
||||
},
|
||||
],
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.displayLanguage": "en",
|
||||
},
|
||||
}
|
||||
|
|
42
frontend/src/app.css
Normal file
42
frontend/src/app.css
Normal file
|
@ -0,0 +1,42 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'bootstrap-icons';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
emerald --default;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "darkgray";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(37% 0.034 259.733);
|
||||
--color-base-200: oklch(27% 0.033 256.848);
|
||||
--color-base-300: oklch(21% 0.006 285.885);
|
||||
--color-base-content: oklch(96% 0.001 286.375);
|
||||
--color-primary: oklch(70% 0.14 182.503);
|
||||
--color-primary-content: oklch(98% 0.014 180.72);
|
||||
--color-secondary: oklch(65% 0.241 354.308);
|
||||
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||
--color-accent: oklch(58% 0.233 277.117);
|
||||
--color-accent-content: oklch(96% 0.018 272.314);
|
||||
--color-neutral: oklch(20% 0 0);
|
||||
--color-neutral-content: oklch(96% 0.001 286.375);
|
||||
--color-info: oklch(74% 0.16 232.661);
|
||||
--color-info-content: oklch(95% 0.026 236.824);
|
||||
--color-success: oklch(76% 0.177 163.223);
|
||||
--color-success-content: oklch(26% 0.051 172.552);
|
||||
--color-warning: oklch(82% 0.189 84.429);
|
||||
--color-warning-content: oklch(27% 0.077 45.635);
|
||||
--color-error: oklch(64% 0.246 16.439);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
49
frontend/src/lib/component/DashboardCard.svelte
Normal file
49
frontend/src/lib/component/DashboardCard.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import i18n from '$lib/i18n'
|
||||
|
||||
let props: {
|
||||
title: string
|
||||
icon: string
|
||||
value: number
|
||||
previousValue: number
|
||||
unit?: string
|
||||
} = $props()
|
||||
|
||||
let diff = $derived(
|
||||
Math.round(((props.previousValue - props.value) / props.previousValue) * 100 * 10 * -1) / 10
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow rounded-md basis-0 grow">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-start justify-between gap-2 text-sm">
|
||||
<div>
|
||||
<p class="text-base-content/80 font-medium">
|
||||
{props.title}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<p class="text-2xl font-semibold">
|
||||
{props.value}{#if props.unit}{' ' + props.unit}{/if}
|
||||
</p>
|
||||
{#if diff > 0}
|
||||
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
|
||||
<i class="bi bi-arrow-up-right"></i>{diff}%
|
||||
</div>
|
||||
{:else if diff < 0}
|
||||
<div class="badge badge-soft badge-error badge-sm gap-0.5 px-1 font-medium">
|
||||
<i class="bi bi-arrow-down-right"></i>{diff}%
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-soft badge-warning badge-sm gap-0.5 px-1 font-medium">
|
||||
<i class="bi bi-arrow-right"></i>{diff}%
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi {props.icon} text-primary text-4xl"></i>
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{$i18n.t('dashboard:cards.lastMonth', { val: props.previousValue, unit: props.unit })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
94
frontend/src/lib/component/TransactionTable.svelte
Normal file
94
frontend/src/lib/component/TransactionTable.svelte
Normal file
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts">
|
||||
import i18n from '$lib/i18n'
|
||||
|
||||
let props: {
|
||||
transactions: {
|
||||
id: string
|
||||
begin: Date
|
||||
end: Date
|
||||
chargepoint: {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
energyAmmount: number
|
||||
cost: number
|
||||
}[]
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto mt-4 bg-base-100 rounded-md">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$i18n.t('common:transactionTable.headerDate')}</th>
|
||||
<th>{$i18n.t('common:transactionTable.headerChargepoint')}</th>
|
||||
<th>{$i18n.t('common:transactionTable.headerEnergyTotal')}</th>
|
||||
<th>{$i18n.t('common:transactionTable.headerCost')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if props.transactions.length > 0}
|
||||
{#each props.transactions as transaction}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<p>
|
||||
{$i18n.t('common:transactionTable.startTime', {
|
||||
time: transaction.begin,
|
||||
formatParams: {
|
||||
time: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
},
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{$i18n.t('common:transactionTable.endTime', {
|
||||
time: transaction.end,
|
||||
formatParams: {
|
||||
time: {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
},
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/chargepoint/{transaction.chargepoint.id}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plug-fill text-lg"></i>
|
||||
{transaction.chargepoint.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-bold">{transaction.energyAmmount} kWh</td>
|
||||
<td class="font-bold">{transaction.cost} €</td>
|
||||
<th>
|
||||
<a href="/transaction/{transaction.id}" class="btn btn-sm btn-primary">
|
||||
{$i18n.t('common:transactionTable.detailButton')}
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if props.transactions.length === 0}
|
||||
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
|
||||
{$i18n.t('common:transactionTable.noPreviousTransactions')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
55
frontend/src/lib/i18n-store.ts
Normal file
55
frontend/src/lib/i18n-store.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import type { i18n } from 'i18next'
|
||||
import { writable, type Readable, type Writable } from 'svelte/store'
|
||||
|
||||
export interface TranslationService {
|
||||
i18n: Readable<i18n>
|
||||
}
|
||||
|
||||
export const isLoading = writable(true)
|
||||
|
||||
export class I18NextTranslationStore implements TranslationService {
|
||||
public i18n: Writable<i18n>
|
||||
public isLoading: Writable<boolean>
|
||||
|
||||
constructor(i18n: i18n) {
|
||||
this.i18n = this.createInstance(i18n)
|
||||
this.isLoading = this.createLoadingInstance(i18n)
|
||||
}
|
||||
|
||||
private createInstance(i18n: i18n): Writable<i18n> {
|
||||
const i18nWritable = writable(i18n)
|
||||
|
||||
i18n.on('initialized', () => {
|
||||
i18nWritable.set(i18n)
|
||||
})
|
||||
i18n.on('loaded', () => {
|
||||
i18nWritable.set(i18n)
|
||||
})
|
||||
i18n.on('added', () => i18nWritable.set(i18n))
|
||||
i18n.on('languageChanged', () => {
|
||||
i18nWritable.set(i18n)
|
||||
})
|
||||
return i18nWritable
|
||||
}
|
||||
|
||||
private createLoadingInstance(i18n: i18n): Writable<boolean> {
|
||||
// if loaded resources are empty || {}, set loading to true
|
||||
i18n.on('loaded', (resources) => {
|
||||
if (Object.keys(resources).length !== 0) {
|
||||
isLoading.set(false)
|
||||
}
|
||||
})
|
||||
|
||||
// if resources failed loading, set loading to true
|
||||
i18n.on('failedLoading', () => {
|
||||
isLoading.set(true)
|
||||
})
|
||||
|
||||
return isLoading
|
||||
}
|
||||
}
|
||||
|
||||
export const createI18nStore = (i18n: i18n) => {
|
||||
const i18nStore = new I18NextTranslationStore(i18n)
|
||||
return i18nStore.i18n
|
||||
}
|
32
frontend/src/lib/i18n.ts
Normal file
32
frontend/src/lib/i18n.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import i18next from 'i18next'
|
||||
import Backend from 'i18next-chained-backend'
|
||||
import Fetch from 'i18next-fetch-backend'
|
||||
import LocalStorageBackend from 'i18next-localstorage-backend'
|
||||
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { createI18nStore } from './i18n-store'
|
||||
|
||||
i18next
|
||||
.use(Backend)
|
||||
.use(I18nextBrowserLanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'de'],
|
||||
ns: ['common'],
|
||||
defaultNS: 'common',
|
||||
backend: {
|
||||
backends: [LocalStorageBackend, Fetch],
|
||||
backendOptions: [
|
||||
{
|
||||
expirationTime: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
const i18n = createI18nStore(i18next)
|
||||
export default i18n
|
|
@ -1 +0,0 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
23
frontend/src/lib/persistent-store.ts
Normal file
23
frontend/src/lib/persistent-store.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
interface PersistedSettings {
|
||||
darkmode: boolean
|
||||
loggedIn: boolean
|
||||
friendlyName: string
|
||||
email: string
|
||||
role: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
const settingsDefault: PersistedSettings = {
|
||||
darkmode: false,
|
||||
loggedIn: false,
|
||||
friendlyName: "",
|
||||
email: "",
|
||||
role: "member",
|
||||
refreshToken: ""
|
||||
}
|
||||
|
||||
export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
|
||||
|
||||
persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
|
10
frontend/src/lib/util.ts
Normal file
10
frontend/src/lib/util.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export const currentDaytime = function(): string {
|
||||
const currentHour = new Date().getHours()
|
||||
|
||||
if (currentHour >= 0 && currentHour < 12) {
|
||||
return 'morning'
|
||||
} else if (currentHour >= 12 && currentHour < 18) {
|
||||
return 'day'
|
||||
}
|
||||
return 'evening'
|
||||
}
|
97
frontend/src/routes/(navbar)/+layout.svelte
Normal file
97
frontend/src/routes/(navbar)/+layout.svelte
Normal file
|
@ -0,0 +1,97 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { persistentSettings } from '$lib/persistent-store'
|
||||
import i18n from '$lib/i18n'
|
||||
let { children } = $props()
|
||||
|
||||
if (!$persistentSettings.loggedIn) {
|
||||
goto('/login')
|
||||
}
|
||||
|
||||
let drawerOpen = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full p-4 flex flex-col bg-base-200">
|
||||
<div class="navbar w-auto mb-4 pr-4 bg-base-100 shadow-md rounded-md">
|
||||
<div class="drawer navbar-start">
|
||||
<input bind:checked={drawerOpen} id="nav-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content">
|
||||
<label for="nav-drawer" class="btn btn-ghost btn-circle drawer-button">
|
||||
<i class="bi bi-list text-2xl"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="drawer-side z-10">
|
||||
<label for="nav-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<ul class="menu bg-base-100 text-base min-h-full w-80 p-4">
|
||||
<li class="mb-4">
|
||||
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onclick={() => {
|
||||
drawerOpen = !drawerOpen
|
||||
}}
|
||||
href="/"
|
||||
>
|
||||
<i class="bi bi-graph-up text-xl"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onclick={() => {
|
||||
drawerOpen = !drawerOpen
|
||||
}}
|
||||
href="/idtoken"
|
||||
>
|
||||
<i class="bi bi-credit-card-fill text-xl"></i>
|
||||
<span>{$i18n.t('common:navbar.link.idtoken')}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onclick={() => {
|
||||
drawerOpen = !drawerOpen
|
||||
}}
|
||||
href="/transaction"
|
||||
>
|
||||
<i class="bi bi-battery-charging text-xl"></i>
|
||||
<span>{$i18n.t('common:navbar.link.transaction')}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onclick={() => {
|
||||
drawerOpen = !drawerOpen
|
||||
}}
|
||||
href="/chargepoint"
|
||||
>
|
||||
<i class="bi bi-plug-fill text-xl"></i>
|
||||
<span>{$i18n.t('common:navbar.link.chargepoint')}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center">
|
||||
<a class="btn btn-ghost text-xl" href="/">LibreCharge</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||
<i class="bi bi-person-circle text-2xl"></i>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li>
|
||||
<li><a href="/login?logout">{$i18n.t('common:navbar.link.logout')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
121
frontend/src/routes/(navbar)/+page.svelte
Normal file
121
frontend/src/routes/(navbar)/+page.svelte
Normal file
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import { persistentSettings } from '$lib/persistent-store'
|
||||
import { currentDaytime } from '$lib/util'
|
||||
import i18n from '$lib/i18n'
|
||||
import DashboardCard from '$lib/component/DashboardCard.svelte'
|
||||
import TransactionTable from '$lib/component/TransactionTable.svelte'
|
||||
|
||||
$i18n.loadNamespaces('dashboard')
|
||||
|
||||
let hasActiveTransaction = $state(true)
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full mt-10 flex flex-col">
|
||||
<p class="w-full text-2xl font-bold">
|
||||
{$i18n.t('dashboard:greeting.' + currentDaytime(), { name: $persistentSettings.friendlyName })}
|
||||
</p>
|
||||
<div class="w-full mt-5 flex gap-x-4">
|
||||
<div class="card bg-base-100 shadow rounded-md basis-0 grow">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-start justify-between gap-2 text-sm">
|
||||
<div>
|
||||
<p class="text-base-content/80 font-medium">
|
||||
{$i18n.t('dashboard:cards.currentTransaction')}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
{#if hasActiveTransaction}
|
||||
<div class="inline-grid *:[grid-area:1/1]">
|
||||
<div class="status status-success animate-ping"></div>
|
||||
<div class="status status-success"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-2xl font-semibold">
|
||||
{#if hasActiveTransaction}0,25 kWh{:else}-{/if}
|
||||
</p>
|
||||
{#if hasActiveTransaction}
|
||||
<div class="badge badge-soft badge-success badge-sm gap-0.5 px-1 font-medium">
|
||||
2,30 €
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi bi-plug-fill text-primary text-4xl"></i>
|
||||
</div>
|
||||
<div class="w-full flex flex-row items-center">
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{#if hasActiveTransaction}
|
||||
{$i18n.t('dashboard:cards.chargepoint', { name: 'DE-EXMPL-0001' })}
|
||||
{:else}
|
||||
{$i18n.t('dashboard:cards.noCurrentTransaction')}
|
||||
{/if}
|
||||
</p>
|
||||
{#if hasActiveTransaction}
|
||||
<button class="btn btn-xs btn-primary">
|
||||
{$i18n.t('dashboard:cards.toCurrentTransactionButton')}
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardCard
|
||||
title={$i18n.t('dashboard:cards.transactionCount')}
|
||||
icon={'bi-battery-charging'}
|
||||
value={3}
|
||||
previousValue={1}
|
||||
/>
|
||||
<DashboardCard
|
||||
title={$i18n.t('dashboard:cards.transactionEnergyTotal')}
|
||||
icon={'bi-lightning-charge-fill'}
|
||||
value={180}
|
||||
previousValue={50}
|
||||
unit={'kWh'}
|
||||
/>
|
||||
<DashboardCard
|
||||
title={$i18n.t('dashboard:cards.transactionCostTotal')}
|
||||
icon={'bi-currency-exchange'}
|
||||
value={30.56}
|
||||
previousValue={30.56}
|
||||
unit={'€'}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full flex flex-col mt-10">
|
||||
<p class="text-xl font-bold">{$i18n.t('dashboard:table.title')}</p>
|
||||
<TransactionTable
|
||||
transactions={[
|
||||
{
|
||||
id: '1',
|
||||
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
|
||||
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
|
||||
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
|
||||
energyAmmount: 58.65,
|
||||
cost: 36.45,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
|
||||
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
|
||||
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
|
||||
energyAmmount: 58.65,
|
||||
cost: 36.45,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
|
||||
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
|
||||
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
|
||||
energyAmmount: 58.65,
|
||||
cost: 36.45,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
begin: new Date(Date.UTC(2025, 3, 15, 14, 12, 23)),
|
||||
end: new Date(Date.UTC(2025, 3, 15, 16, 18, 46)),
|
||||
chargepoint: { name: 'DE-EXMPL-0001', id: '1' },
|
||||
energyAmmount: 58.65,
|
||||
cost: 36.45,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
0
frontend/src/routes/(navbar)/chargepoint/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/chargepoint/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/idtoken/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/idtoken/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/profile/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/profile/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/transaction/+page.svelte
Normal file
0
frontend/src/routes/(navbar)/transaction/+page.svelte
Normal file
38
frontend/src/routes/+layout.svelte
Normal file
38
frontend/src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import { persistentSettings } from '$lib/persistent-store'
|
||||
let { children } = $props()
|
||||
import '../app.css'
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-screen h-screen flex flex-col"
|
||||
data-theme={$persistentSettings.darkmode ? 'darkgray' : 'emerald'}
|
||||
>
|
||||
{@render children()}
|
||||
<footer
|
||||
class="footer sm:footer-horizontal bg-neutral text-neutral-content items-center pt-3 pb-3 pl-6 pr-6"
|
||||
>
|
||||
<aside class="grid-flow-col">
|
||||
<p>Powered by LibreCharge - Licensed under GNU AGPL v3</p>
|
||||
</aside>
|
||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end text-3xl">
|
||||
<label class="swap swap-rotate">
|
||||
<!-- this hidden checkbox controls the state -->
|
||||
<input type="checkbox" bind:checked={$persistentSettings.darkmode} />
|
||||
|
||||
<!-- sun icon -->
|
||||
<i class="bi bi-sun swap-off"></i>
|
||||
|
||||
<!-- moon icon -->
|
||||
<i class="bi bi-moon swap-on"></i>
|
||||
</label>
|
||||
<a
|
||||
aria-label="Source Code"
|
||||
href="https://git.bluemedia.dev/Bluemedia/simple-ocpp-cs"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="bi bi-git"></i>
|
||||
</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
|
@ -1,6 +0,0 @@
|
|||
<script>
|
||||
import '../style.css'
|
||||
</script>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
57
frontend/src/routes/login/+page.svelte
Normal file
57
frontend/src/routes/login/+page.svelte
Normal file
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition'
|
||||
import i18n from '$lib/i18n'
|
||||
$i18n.loadNamespaces('login')
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const isReauth = urlParams.has('reauth')
|
||||
const isLogout = urlParams.has('logout')
|
||||
|
||||
let showToast = $state(true)
|
||||
if (isReauth || isLogout) {
|
||||
setTimeout(() => {
|
||||
showToast = false
|
||||
}, 6000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
|
||||
<div class="basis-0 grow flex justify-center items-center">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<i class="bi bi-ev-front-fill text-8xl text-primary"></i>
|
||||
<p class="text-2xl font-bold">LibreCharge</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="basis-0 flex justify-center items-center">
|
||||
<div class="card bg-base-100 w-full max-w-sm h-fit shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h1 class="text-2xl font-bold">{$i18n.t('login:title')}</h1>
|
||||
<p class="py-2 text-base">{$i18n.t('login:text')}</p>
|
||||
<fieldset class="fieldset">
|
||||
<label class="fieldset-label input text-base-content">
|
||||
<i class="bi bi-envelope-at"></i>
|
||||
<input type="email" placeholder="me@example.com" required />
|
||||
</label>
|
||||
<label class="fieldset-label input text-base-content">
|
||||
<i class="bi bi-key"></i>
|
||||
<input type="password" placeholder={$i18n.t('login:passwordPlaceholder')} required />
|
||||
</label>
|
||||
<button class="btn btn-primary mt-4">{$i18n.t('login:button')}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="basis-0 grow"></span>
|
||||
{#if (isReauth || isLogout) && showToast}
|
||||
<div class="toast toast-top" out:fly={{ x: 200 }}>
|
||||
<div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}">
|
||||
{#if isReauth}
|
||||
<i class="bi bi-exclamation-diamond text-xl"></i>
|
||||
{:else}
|
||||
<i class="bi bi-check-circle text-xl"></i>
|
||||
{/if}
|
||||
<span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
|
@ -1,6 +0,0 @@
|
|||
@import 'tailwindcss';
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light --default,
|
||||
dark --prefersdark;
|
||||
}
|
21
frontend/static/locales/de/common.json
Normal file
21
frontend/static/locales/de/common.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"navbar": {
|
||||
"link": {
|
||||
"idtoken": "Ladekarten",
|
||||
"transaction": "Ladevorgänge",
|
||||
"chargepoint": "Ladestationen",
|
||||
"profile": "Profil",
|
||||
"logout": "Abmelden"
|
||||
}
|
||||
},
|
||||
"transactionTable": {
|
||||
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
|
||||
"headerDate": "Zeitpunkt",
|
||||
"headerChargepoint": "Ladestation",
|
||||
"headerEnergyTotal": "Ladevolumen",
|
||||
"headerCost": "Kosten",
|
||||
"startTime": "Beginn: {{ time, datetime }} Uhr",
|
||||
"endTime": "Ende: {{ time, datetime }} Uhr",
|
||||
"detailButton": "Details"
|
||||
}
|
||||
}
|
28
frontend/static/locales/de/dashboard.json
Normal file
28
frontend/static/locales/de/dashboard.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"greeting": {
|
||||
"morning": "Guten Morgen {{name}}!",
|
||||
"day": "Guten Tag {{name}}!",
|
||||
"evening": "Guten Abend {{name}}!"
|
||||
},
|
||||
"cards": {
|
||||
"currentTransaction": "Aktueller Ladevorgang",
|
||||
"chargepoint": "Ladestation: {{name}}",
|
||||
"toCurrentTransactionButton": "Zum Ladevorgang",
|
||||
"noCurrentTransaction": "Kein Ladevorgang aktiv",
|
||||
"transactionCount": "Ladevorgänge",
|
||||
"transactionEnergyTotal": "Ladevolumen",
|
||||
"transactionCostTotal": "Kosten",
|
||||
"lastMonth": "{{val}} {{unit}} im letzten Monat"
|
||||
},
|
||||
"table": {
|
||||
"title": "Deine letzten Ladevorgänge",
|
||||
"noPreviousTransactions": "Du hast noch keine abgeschlossenen Ladevorgänge.",
|
||||
"headerDate": "Zeitpunkt",
|
||||
"headerChargepoint": "Ladestation",
|
||||
"headerEnergyTotal": "Ladevolumen",
|
||||
"headerCost": "Kosten",
|
||||
"startTime": "Beginn: {{ time, datetime }} Uhr",
|
||||
"endTime": "Ende: {{ time, datetime }} Uhr",
|
||||
"detailButton": "Details"
|
||||
}
|
||||
}
|
10
frontend/static/locales/de/login.json
Normal file
10
frontend/static/locales/de/login.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"title": "Anmelden",
|
||||
"text": "Bitte melde dich mit deinem Account an:",
|
||||
"passwordPlaceholder": "Passwort",
|
||||
"button": "Anmelden",
|
||||
"toast": {
|
||||
"reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||
"logoutSuccess": "Abmeldung erfolgreich."
|
||||
}
|
||||
}
|
21
frontend/static/locales/en/common.json
Normal file
21
frontend/static/locales/en/common.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"navbar": {
|
||||
"link": {
|
||||
"idtoken": "Charging Cards",
|
||||
"transaction": "Transactions",
|
||||
"chargepoint": "Charging Stations",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout"
|
||||
}
|
||||
},
|
||||
"transactionTable": {
|
||||
"noPreviousTransactions": "You don't have any completed transactions yet.",
|
||||
"headerDate": "Date",
|
||||
"headerChargepoint": "Chargepoint",
|
||||
"headerEnergyTotal": "Energy charged",
|
||||
"headerCost": "Cost",
|
||||
"startTime": "Start: {{ time, datetime }}",
|
||||
"endTime": "End: {{ time, datetime }}",
|
||||
"detailButton": "Details"
|
||||
}
|
||||
}
|
28
frontend/static/locales/en/dashboard.json
Normal file
28
frontend/static/locales/en/dashboard.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"greeting": {
|
||||
"morning": "Good morning {{name}}!",
|
||||
"day": "Hello {{name}}!",
|
||||
"evening": "Good evening {{name}}!"
|
||||
},
|
||||
"cards": {
|
||||
"currentTransaction": "Current Transaction",
|
||||
"chargepoint": "Chargepoint: {{name}}",
|
||||
"toCurrentTransactionButton": "More",
|
||||
"noCurrentTransaction": "No active transaction",
|
||||
"transactionCount": "Transactions",
|
||||
"transactionEnergyTotal": "Energy Ammount",
|
||||
"transactionCostTotal": "Cost",
|
||||
"lastMonth": "{{val}} {{unit}} last month"
|
||||
},
|
||||
"table": {
|
||||
"title": "Your recent transactions",
|
||||
"noPreviousTransactions": "You don't have any completed transactions yet.",
|
||||
"headerDate": "Date",
|
||||
"headerChargepoint": "Chargepoint",
|
||||
"headerEnergyTotal": "Energy charged",
|
||||
"headerCost": "Cost",
|
||||
"startTime": "Start: {{ time, datetime }}",
|
||||
"endTime": "End: {{ time, datetime }}",
|
||||
"detailButton": "Details"
|
||||
}
|
||||
}
|
10
frontend/static/locales/en/login.json
Normal file
10
frontend/static/locales/en/login.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"title": "Login",
|
||||
"text": "Please login to your account:",
|
||||
"passwordPlaceholder": "Password",
|
||||
"button": "Login",
|
||||
"toast": {
|
||||
"reauth": "Your session expired. Please log in again.",
|
||||
"logoutSuccess": "Successfully logged out."
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue