Compare commits
9 commits
48bbcac9a6
...
fac5d26220
| Author | SHA1 | Date | |
|---|---|---|---|
| fac5d26220 | |||
| 1b54536647 | |||
| d54f98d007 | |||
| 461473d379 | |||
| e40574ae9d | |||
| cc9b951b05 | |||
| b91f6f81f8 | |||
| 17dc49482c | |||
| 85699fb9e5 |
25 changed files with 1446 additions and 25 deletions
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""add created_at to cyoa audio and possible choice
|
||||||
|
|
||||||
|
Revision ID: 0017
|
||||||
|
Revises: 0016
|
||||||
|
Create Date: 2026-05-06
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0017"
|
||||||
|
down_revision: Union[str, None] = "0016"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"choose_your_own_adventure_entry_audio",
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"choose_your_own_adventure_entry_possible_choice",
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("choose_your_own_adventure_entry_possible_choice", "created_at")
|
||||||
|
op.drop_column("choose_your_own_adventure_entry_audio", "created_at")
|
||||||
|
|
@ -144,6 +144,7 @@ class AdventureService:
|
||||||
self,
|
self,
|
||||||
adventure_id: uuid.UUID,
|
adventure_id: uuid.UUID,
|
||||||
entry_id: uuid.UUID,
|
entry_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Full entry generation pipeline. Called from the worker queue.
|
"""Full entry generation pipeline. Called from the worker queue.
|
||||||
|
|
||||||
|
|
@ -157,6 +158,7 @@ class AdventureService:
|
||||||
assert adventure is not None, f"Adventure {adventure_id} not found"
|
assert adventure is not None, f"Adventure {adventure_id} not found"
|
||||||
|
|
||||||
all_entries = await self.entry_repo.list_for_adventure(adventure_id)
|
all_entries = await self.entry_repo.list_for_adventure(adventure_id)
|
||||||
|
all_decisions = [await self.decision_repo.get_for_entry_and_user(entry_id=uuid.UUID(e.id), user_id=user_id) for e in all_entries]
|
||||||
current_entry = next(e for e in all_entries if e.id == str(entry_id))
|
current_entry = next(e for e in all_entries if e.id == str(entry_id))
|
||||||
is_first_entry = current_entry.entry_index == 0
|
is_first_entry = current_entry.entry_index == 0
|
||||||
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
||||||
|
|
@ -184,6 +186,7 @@ class AdventureService:
|
||||||
vibes=adventure.vibes,
|
vibes=adventure.vibes,
|
||||||
protagonist=adventure.protagonist,
|
protagonist=adventure.protagonist,
|
||||||
prior_entries=prior_entries,
|
prior_entries=prior_entries,
|
||||||
|
prior_decisions=all_decisions,
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_text, usage_dict = await self.anthropic_client.complete(
|
raw_text, usage_dict = await self.anthropic_client.complete(
|
||||||
|
|
@ -222,7 +225,8 @@ class AdventureService:
|
||||||
)
|
)
|
||||||
|
|
||||||
voice = self.gemini_client.get_voice_by_language(adventure.language)
|
voice = self.gemini_client.get_voice_by_language(adventure.language)
|
||||||
wav_bytes = await self.gemini_client.generate_audio(story_text, voice)
|
story_text_with_tag = "[like a dungeons and dragons gamemaster] " + story_text
|
||||||
|
wav_bytes = await self.gemini_client.generate_audio(story_text_with_tag, voice)
|
||||||
audio_key = f"adventure-audio/{entry_id}.wav"
|
audio_key = f"adventure-audio/{entry_id}.wav"
|
||||||
upload_audio(audio_key, wav_bytes)
|
upload_audio(audio_key, wav_bytes)
|
||||||
await self.audio_repo.create(
|
await self.audio_repo.create(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ def build_entry_system_prompt(
|
||||||
f"only \"-----\".\n"
|
f"only \"-----\".\n"
|
||||||
f"- Part 1: the story entry, {min_length}–{max_length} words, speaking directly to the player.\n"
|
f"- Part 1: the story entry, {min_length}–{max_length} words, speaking directly to the player.\n"
|
||||||
f"- Part 2: exactly 4 numbered player options, one per line, labelled \"1.\", \"2.\", \"3.\", \"4.\".\n"
|
f"- Part 2: exactly 4 numbered player options, one per line, labelled \"1.\", \"2.\", \"3.\", \"4.\".\n"
|
||||||
f"- Part 3: GM notes to your future self (hidden from the player). "
|
f"- Part 3: GM notes to your future self (hidden from the player). \n"
|
||||||
f"If no notes, write \"no notes\".\n"
|
f"If no notes, write \"no notes\".\n"
|
||||||
f"- Your first message must establish: who the player is, the setting, and the broad direction.\n"
|
f"- Your first message must establish: who the player is, the setting, and the broad direction.\n"
|
||||||
f"- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate)."
|
f"- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate)."
|
||||||
|
|
@ -96,6 +96,7 @@ def build_conversation_messages(
|
||||||
vibes: list[str],
|
vibes: list[str],
|
||||||
protagonist: list[str],
|
protagonist: list[str],
|
||||||
prior_entries: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
|
prior_entries: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
|
||||||
|
prior_decisions: list[AdventureEntryPossibleChoice | None],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the full messages array for an Anthropic API call.
|
"""Build the full messages array for an Anthropic API call.
|
||||||
|
|
||||||
|
|
@ -110,8 +111,22 @@ def build_conversation_messages(
|
||||||
messages.append(
|
messages.append(
|
||||||
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
|
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
|
||||||
)
|
)
|
||||||
if chosen_label is not None:
|
|
||||||
messages.append({"role": "user", "content": chosen_label})
|
# Find the player's decision for this entry
|
||||||
|
choice_ids = [c.id for c in choices]
|
||||||
|
decision_for_entry = next(
|
||||||
|
(d for d in prior_decisions if d and d.choice_id in choice_ids),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
# If a decision exists, append the player's chosen option
|
||||||
|
if decision_for_entry:
|
||||||
|
chosen_option = next(
|
||||||
|
(c for c in choices if c.id == decision_for_entry.choice_id),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if chosen_option:
|
||||||
|
messages.append({"role": "user", "content": chosen_option.label})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ class AdventureEntryPossibleChoiceEntity(Base):
|
||||||
index: Mapped[int] = mapped_column(Integer, nullable=False)
|
index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
label: Mapped[str] = mapped_column(Text, nullable=False)
|
label: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdventureEntryPossibleChoiceDecisionEntity(Base):
|
class AdventureEntryPossibleChoiceDecisionEntity(Base):
|
||||||
|
|
@ -142,3 +145,6 @@ class AdventureEntryAudioEntity(Base):
|
||||||
tts_provider: Mapped[str] = mapped_column(Text, nullable=False, default="google_gemini")
|
tts_provider: Mapped[str] = mapped_column(Text, nullable=False, default="google_gemini")
|
||||||
tts_options: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
tts_options: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
file_name: Mapped[str] = mapped_column(Text, nullable=False)
|
file_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,10 @@ def _make_service(db: AsyncSession) -> AdventureService:
|
||||||
|
|
||||||
|
|
||||||
async def _run_entry_pipeline_task(
|
async def _run_entry_pipeline_task(
|
||||||
adventure_id: uuid.UUID, entry_id: uuid.UUID
|
adventure_id: uuid.UUID, entry_id: uuid.UUID, user_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await _make_service(db).run_entry_pipeline(adventure_id, entry_id)
|
await _make_service(db).run_entry_pipeline(adventure_id, entry_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -289,7 +289,7 @@ async def create_adventure(
|
||||||
)
|
)
|
||||||
await worker.enqueue(
|
await worker.enqueue(
|
||||||
partial(
|
partial(
|
||||||
_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id)
|
_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id), user_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return _to_adventure_response(adventure)
|
return _to_adventure_response(adventure)
|
||||||
|
|
@ -375,6 +375,7 @@ async def record_decision(
|
||||||
_run_entry_pipeline_task,
|
_run_entry_pipeline_task,
|
||||||
uuid.UUID(next_entry.adventure_id),
|
uuid.UUID(next_entry.adventure_id),
|
||||||
uuid.UUID(next_entry.id),
|
uuid.UUID(next_entry.id),
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return DecisionResponse(
|
return DecisionResponse(
|
||||||
|
|
|
||||||
145
api/app/routers/bff/adventure.py
Normal file
145
api/app/routers/bff/adventure.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ...auth import verify_token
|
||||||
|
from ...config import settings
|
||||||
|
from ...outbound.postgres.database import get_db
|
||||||
|
from ...outbound.postgres.repositories.adventure_repository import (
|
||||||
|
PostgresAdventureEntryAudioRepository,
|
||||||
|
PostgresAdventureEntryChoiceRepository,
|
||||||
|
PostgresAdventureEntryRepository,
|
||||||
|
PostgresAdventureEntryTranslationRepository,
|
||||||
|
PostgresAdventureRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/adventure", tags=["bff", "adventures"])
|
||||||
|
|
||||||
|
|
||||||
|
class AdventureChoiceItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
index: int
|
||||||
|
label: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdventureEntryItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
adventure_id: str
|
||||||
|
possible_choices: list[AdventureChoiceItem] | None
|
||||||
|
generated_from_choice_id: str | None
|
||||||
|
status: str
|
||||||
|
entry_index: int
|
||||||
|
story_text: str | None
|
||||||
|
translation: str | None
|
||||||
|
audio_url: str | None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdventureDetailResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
status: str
|
||||||
|
language: str
|
||||||
|
source_language: str
|
||||||
|
competencies: list[str]
|
||||||
|
max_entry_count: int
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
genres: list[str]
|
||||||
|
setting: list[str]
|
||||||
|
vibes: list[str]
|
||||||
|
protagonist: list[str]
|
||||||
|
created_at: str
|
||||||
|
entries: list[AdventureEntryItem]
|
||||||
|
current_entry_choices: list[AdventureChoiceItem]
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_url(key: str | None) -> str | None:
|
||||||
|
if key is None:
|
||||||
|
return None
|
||||||
|
return f"{settings.api_base_url}/media/{key}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{adventure_id}", response_model=AdventureDetailResponse, status_code=200)
|
||||||
|
async def get_adventure(
|
||||||
|
adventure_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
token_data: dict = Depends(verify_token),
|
||||||
|
) -> AdventureDetailResponse:
|
||||||
|
user_id = uuid.UUID(token_data["sub"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
adv_id = uuid.UUID(adventure_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid adventure_id")
|
||||||
|
|
||||||
|
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
|
||||||
|
if adventure is None or adventure.user_id != str(user_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||||
|
|
||||||
|
entries = await PostgresAdventureEntryRepository(db).list_for_adventure(adv_id)
|
||||||
|
|
||||||
|
choices_repo = PostgresAdventureEntryChoiceRepository(db)
|
||||||
|
translation_repo = PostgresAdventureEntryTranslationRepository(db)
|
||||||
|
audio_repo = PostgresAdventureEntryAudioRepository(db)
|
||||||
|
|
||||||
|
entry_items = []
|
||||||
|
for entry in entries:
|
||||||
|
eid = uuid.UUID(entry.id)
|
||||||
|
translation = await translation_repo.get_for_entry(
|
||||||
|
entry_id=eid,
|
||||||
|
component_type="story_text",
|
||||||
|
target_language=adventure.source_language,
|
||||||
|
)
|
||||||
|
audio = await audio_repo.get_for_entry(entry_id=eid, component_type="story_text")
|
||||||
|
choices = await choices_repo.list_for_entry(eid)
|
||||||
|
entry_items.append(
|
||||||
|
AdventureEntryItem(
|
||||||
|
id=entry.id,
|
||||||
|
adventure_id=entry.adventure_id,
|
||||||
|
possible_choices=[
|
||||||
|
AdventureChoiceItem(id=c.id, index=c.index, label=c.label, text=c.text)
|
||||||
|
for c in choices ]
|
||||||
|
if choices else None,
|
||||||
|
generated_from_choice_id=entry.generated_from_choice_id,
|
||||||
|
status=entry.status,
|
||||||
|
entry_index=entry.entry_index,
|
||||||
|
story_text=entry.story_text,
|
||||||
|
translation=translation.translated_text if translation else None,
|
||||||
|
audio_url=_audio_url(audio.file_name if audio else None),
|
||||||
|
created_at=entry.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Choices for the most recent entry, if it's complete (entries are ordered entry_index asc)
|
||||||
|
current_entry_choices: list[AdventureChoiceItem] = []
|
||||||
|
if entries and entries[-1].status == "complete":
|
||||||
|
choices = await PostgresAdventureEntryChoiceRepository(db).list_for_entry(
|
||||||
|
uuid.UUID(entries[-1].id)
|
||||||
|
)
|
||||||
|
current_entry_choices = [
|
||||||
|
AdventureChoiceItem(id=c.id, index=c.index, label=c.label, text=c.text)
|
||||||
|
for c in choices
|
||||||
|
]
|
||||||
|
|
||||||
|
return AdventureDetailResponse(
|
||||||
|
id=adventure.id,
|
||||||
|
user_id=adventure.user_id,
|
||||||
|
status=adventure.status,
|
||||||
|
language=adventure.language,
|
||||||
|
source_language=adventure.source_language,
|
||||||
|
competencies=adventure.competencies,
|
||||||
|
max_entry_count=adventure.max_entry_count,
|
||||||
|
title=adventure.title,
|
||||||
|
description=adventure.description,
|
||||||
|
genres=adventure.genres,
|
||||||
|
setting=adventure.setting,
|
||||||
|
vibes=adventure.vibes,
|
||||||
|
protagonist=adventure.protagonist,
|
||||||
|
created_at=adventure.created_at.isoformat(),
|
||||||
|
entries=entry_items,
|
||||||
|
current_entry_choices=current_entry_choices,
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from .account import router as account_router
|
from .account import router as account_router
|
||||||
|
from .adventure import router as adventure_router
|
||||||
from .articles import router as article_router
|
from .articles import router as article_router
|
||||||
from .user_profile import router as user_profile_router
|
from .user_profile import router as user_profile_router
|
||||||
from .packs import router as packs_router
|
from .packs import router as packs_router
|
||||||
|
|
@ -8,6 +9,7 @@ from fastapi import APIRouter
|
||||||
bff_router = APIRouter(prefix="/bff", tags=["bff"])
|
bff_router = APIRouter(prefix="/bff", tags=["bff"])
|
||||||
|
|
||||||
bff_router.include_router(account_router)
|
bff_router.include_router(account_router)
|
||||||
|
bff_router.include_router(adventure_router)
|
||||||
bff_router.include_router(article_router)
|
bff_router.include_router(article_router)
|
||||||
bff_router.include_router(user_profile_router)
|
bff_router.include_router(user_profile_router)
|
||||||
bff_router.include_router(packs_router)
|
bff_router.include_router(packs_router)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -5,11 +7,37 @@ from botocore.exceptions import ClientError
|
||||||
|
|
||||||
from ..outbound.postgres.database import get_db
|
from ..outbound.postgres.database import get_db
|
||||||
from ..outbound.postgres.repositories.translated_article_repository import TranslatedArticleRepository
|
from ..outbound.postgres.repositories.translated_article_repository import TranslatedArticleRepository
|
||||||
|
from ..outbound.postgres.repositories.adventure_repository import PostgresAdventureEntryAudioRepository
|
||||||
from ..storage import download_audio
|
from ..storage import download_audio
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adventure-audio/{filename:path}")
|
||||||
|
async def get_adventure_audio_file(
|
||||||
|
filename: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Response:
|
||||||
|
try:
|
||||||
|
eid = uuid.UUID(filename.rsplit(".", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid file ID")
|
||||||
|
|
||||||
|
print(f"Looking for adventure audio with entry ID: {eid}")
|
||||||
|
|
||||||
|
adventure_audio = await PostgresAdventureEntryAudioRepository(db).get_for_entry(entry_id=eid, component_type="story_text")
|
||||||
|
|
||||||
|
if adventure_audio is None:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
try:
|
||||||
|
audio_bytes, content_type = download_audio("adventure-audio/" + filename)
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
raise HTTPException(status_code=500, detail="Storage error")
|
||||||
|
|
||||||
|
return Response(content=audio_bytes, media_type=content_type)
|
||||||
|
|
||||||
@router.get("/{filename:path}")
|
@router.get("/{filename:path}")
|
||||||
async def get_media_file(
|
async def get_media_file(
|
||||||
filename: str,
|
filename: str,
|
||||||
|
|
@ -28,3 +56,4 @@ async def get_media_file(
|
||||||
raise HTTPException(status_code=500, detail="Storage error")
|
raise HTTPException(status_code=500, detail="Storage error")
|
||||||
|
|
||||||
return Response(content=audio_bytes, media_type=content_type)
|
return Response(content=audio_bytes, media_type=content_type)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,11 @@ services:
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./api
|
build: ./api
|
||||||
|
volumes:
|
||||||
|
- ./api:/app:z
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-langlearn}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-langlearn}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-langlearn}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-langlearn}
|
||||||
ADMIN_USER_EMAILS: ${ADMIN_USER_EMAILS:-wilson@thomaswilson.xyz}
|
ADMIN_USER_EMAILS: ${ADMIN_USER_EMAILS:-wilson@thomaswilson.xyz}
|
||||||
|
|
@ -45,6 +48,7 @@ services:
|
||||||
DEEPL_API_KEY: ${DEEPL_API_KEY}
|
DEEPL_API_KEY: ${DEEPL_API_KEY}
|
||||||
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY}
|
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY}
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||||
|
PYTHONPATH: /app
|
||||||
STORAGE_ENDPOINT_URL: http://storage:9000
|
STORAGE_ENDPOINT_URL: http://storage:9000
|
||||||
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-langlearn}
|
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-langlearn}
|
||||||
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -82,6 +82,19 @@
|
||||||
--colour-green-900: #1b5e20;
|
--colour-green-900: #1b5e20;
|
||||||
--colour-green-950: #0b2f10;
|
--colour-green-950: #0b2f10;
|
||||||
|
|
||||||
|
/** Colour: Yellow palette, from Tailwind's yellow palette */
|
||||||
|
--colour-yellow-50: #fffbeb;
|
||||||
|
--colour-yellow-100: #fef3c7;
|
||||||
|
--colour-yellow-200: #fde68a;
|
||||||
|
--colour-yellow-300: #fcd34d;
|
||||||
|
--colour-yellow-400: #fbbf24;
|
||||||
|
--colour-yellow-500: #f59e0b;
|
||||||
|
--colour-yellow-600: #d97706;
|
||||||
|
--colour-yellow-700: #b45309;
|
||||||
|
--colour-yellow-800: #92400e;
|
||||||
|
--colour-yellow-900: #78350f;
|
||||||
|
--colour-yellow-950: #451a03;
|
||||||
|
|
||||||
/* --- Color: On-Surface --- */
|
/* --- Color: On-Surface --- */
|
||||||
--color-on-surface: #2f342e; /* replaces pure black */
|
--color-on-surface: #2f342e; /* replaces pure black */
|
||||||
--color-on-surface-variant: #5c605b;
|
--color-on-surface-variant: #5c605b;
|
||||||
|
|
@ -126,7 +139,7 @@
|
||||||
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
||||||
--leading-loose: 1.8;
|
--leading-loose: 1.8;
|
||||||
|
|
||||||
/* --- Typography: Letter Spacing --- */
|
/* --- Typography: Letter Spacing --- */;
|
||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-normal: 0em;
|
--tracking-normal: 0em;
|
||||||
--tracking-wide: 0.05rem; /* label-md metadata */
|
--tracking-wide: 0.05rem; /* label-md metadata */
|
||||||
|
|
@ -305,7 +318,7 @@ body {
|
||||||
background-color var(--duration-normal) var(--ease-standard);
|
background-color var(--duration-normal) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary, .btn.primary {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-on-primary);
|
color: var(--color-on-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -626,3 +639,8 @@ LAYOUT: APP PAGE
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-page.full-bleed {
|
||||||
|
max-width: none;
|
||||||
|
padding: var(--space-0);
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -192,6 +192,144 @@ export type AddWordRequest = {
|
||||||
source_article_id?: string | null;
|
source_article_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdventureChoiceItem
|
||||||
|
*/
|
||||||
|
export type AdventureChoiceItem = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Index
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* Label
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Text
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdventureDetailResponse
|
||||||
|
*/
|
||||||
|
export type AdventureDetailResponse = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* User Id
|
||||||
|
*/
|
||||||
|
user_id: string;
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
*/
|
||||||
|
status: string;
|
||||||
|
/**
|
||||||
|
* Language
|
||||||
|
*/
|
||||||
|
language: string;
|
||||||
|
/**
|
||||||
|
* Source Language
|
||||||
|
*/
|
||||||
|
source_language: string;
|
||||||
|
/**
|
||||||
|
* Competencies
|
||||||
|
*/
|
||||||
|
competencies: Array<string>;
|
||||||
|
/**
|
||||||
|
* Max Entry Count
|
||||||
|
*/
|
||||||
|
max_entry_count: number;
|
||||||
|
/**
|
||||||
|
* Title
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Description
|
||||||
|
*/
|
||||||
|
description: string | null;
|
||||||
|
/**
|
||||||
|
* Genres
|
||||||
|
*/
|
||||||
|
genres: Array<string>;
|
||||||
|
/**
|
||||||
|
* Setting
|
||||||
|
*/
|
||||||
|
setting: Array<string>;
|
||||||
|
/**
|
||||||
|
* Vibes
|
||||||
|
*/
|
||||||
|
vibes: Array<string>;
|
||||||
|
/**
|
||||||
|
* Protagonist
|
||||||
|
*/
|
||||||
|
protagonist: Array<string>;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Entries
|
||||||
|
*/
|
||||||
|
entries: Array<AdventureEntryItem>;
|
||||||
|
/**
|
||||||
|
* Current Entry Choices
|
||||||
|
*/
|
||||||
|
current_entry_choices: Array<AdventureChoiceItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdventureEntryItem
|
||||||
|
*/
|
||||||
|
export type AdventureEntryItem = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Adventure Id
|
||||||
|
*/
|
||||||
|
adventure_id: string;
|
||||||
|
/**
|
||||||
|
* Possible Choices
|
||||||
|
*/
|
||||||
|
possible_choices: Array<AdventureChoiceItem> | null;
|
||||||
|
/**
|
||||||
|
* Generated From Choice Id
|
||||||
|
*/
|
||||||
|
generated_from_choice_id: string | null;
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
*/
|
||||||
|
status: string;
|
||||||
|
/**
|
||||||
|
* Entry Index
|
||||||
|
*/
|
||||||
|
entry_index: number;
|
||||||
|
/**
|
||||||
|
* Story Text
|
||||||
|
*/
|
||||||
|
story_text: string | null;
|
||||||
|
/**
|
||||||
|
* Translation
|
||||||
|
*/
|
||||||
|
translation: string | null;
|
||||||
|
/**
|
||||||
|
* Audio Url
|
||||||
|
*/
|
||||||
|
audio_url: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdventureResponse
|
* AdventureResponse
|
||||||
*/
|
*/
|
||||||
|
|
@ -2893,6 +3031,36 @@ export type GetOnboardingBffAccountOnboardingGetResponses = {
|
||||||
|
|
||||||
export type GetOnboardingBffAccountOnboardingGetResponse = GetOnboardingBffAccountOnboardingGetResponses[keyof GetOnboardingBffAccountOnboardingGetResponses];
|
export type GetOnboardingBffAccountOnboardingGetResponse = GetOnboardingBffAccountOnboardingGetResponses[keyof GetOnboardingBffAccountOnboardingGetResponses];
|
||||||
|
|
||||||
|
export type GetAdventureBffAdventureAdventureIdGetData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Adventure Id
|
||||||
|
*/
|
||||||
|
adventure_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/bff/adventure/{adventure_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdventureBffAdventureAdventureIdGetErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdventureBffAdventureAdventureIdGetError = GetAdventureBffAdventureAdventureIdGetErrors[keyof GetAdventureBffAdventureAdventureIdGetErrors];
|
||||||
|
|
||||||
|
export type GetAdventureBffAdventureAdventureIdGetResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: AdventureDetailResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdventureBffAdventureAdventureIdGetResponse = GetAdventureBffAdventureAdventureIdGetResponses[keyof GetAdventureBffAdventureAdventureIdGetResponses];
|
||||||
|
|
||||||
export type ListArticlesBffArticlesGetData = {
|
export type ListArticlesBffArticlesGetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -3005,6 +3173,34 @@ export type ListPacksForSelectionBffPacksGetResponses = {
|
||||||
|
|
||||||
export type ListPacksForSelectionBffPacksGetResponse = ListPacksForSelectionBffPacksGetResponses[keyof ListPacksForSelectionBffPacksGetResponses];
|
export type ListPacksForSelectionBffPacksGetResponse = ListPacksForSelectionBffPacksGetResponses[keyof ListPacksForSelectionBffPacksGetResponses];
|
||||||
|
|
||||||
|
export type GetAdventureAudioFileMediaAdventureAudioFilenameGetData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Filename
|
||||||
|
*/
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/media/adventure-audio/{filename}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdventureAudioFileMediaAdventureAudioFilenameGetErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAdventureAudioFileMediaAdventureAudioFilenameGetError = GetAdventureAudioFileMediaAdventureAudioFilenameGetErrors[keyof GetAdventureAudioFileMediaAdventureAudioFilenameGetErrors];
|
||||||
|
|
||||||
|
export type GetAdventureAudioFileMediaAdventureAudioFilenameGetResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetMediaFileMediaFilenameGetData = {
|
export type GetMediaFileMediaFilenameGetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||||
} else {
|
} else {
|
||||||
event.locals.isAdmin = false;
|
event.locals.isAdmin = false;
|
||||||
console.log(`Not valid and no token`);
|
console.log(`Not valid and no token`);
|
||||||
|
event.cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pathname } = event.url;
|
const { pathname } = event.url;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
let successMessage: null | string = null;
|
let successMessage: null | string = null;
|
||||||
if (url.searchParams.get('created')) {
|
if (url.searchParams.get('created')) {
|
||||||
successMessage = `Adventure created, check back in a few minutes`;
|
successMessage = `Adventure created, check back in a few minutes`;
|
||||||
url.searchParams.delete('created');
|
url.searchParams.delete('created');
|
||||||
url.searchParams.
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
successMessage
|
successMessage
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,10 @@
|
||||||
|
|
||||||
{#each adventures as adventure (adventure.id)}
|
{#each adventures as adventure (adventure.id)}
|
||||||
<div class="adventure-card">
|
<div class="adventure-card">
|
||||||
<h2>{adventure.title}</h2>
|
<a href={resolve('/app/adventures/[id]', { id: adventure.id })}>
|
||||||
<p>{adventure.description}</p>
|
<h2>{adventure.title}</h2>
|
||||||
|
<p>{adventure.description}</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
29
frontend/src/routes/app/adventures/[id]/+page.server.ts
Normal file
29
frontend/src/routes/app/adventures/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { getAdventureBffAdventureAdventureIdGet } from '@client';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
|
const response = await getAdventureBffAdventureAdventureIdGet({
|
||||||
|
path: {
|
||||||
|
adventure_id: params.id
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${locals.authToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error(`Error fetching an adventure:`);
|
||||||
|
console.error({ error: response.error });
|
||||||
|
return error(400, `Error loading adventure`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, entries, current_entry_choices } = response.data;
|
||||||
|
|
||||||
|
response.data.entries.forEach((e) => console.log(e.story_text));
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
entries,
|
||||||
|
choices: current_entry_choices
|
||||||
|
};
|
||||||
|
};
|
||||||
92
frontend/src/routes/app/adventures/[id]/+page.svelte
Normal file
92
frontend/src/routes/app/adventures/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import LatestEntry from './LatestEntry.svelte';
|
||||||
|
import PreviousEntries from './PreviousEntries.svelte';
|
||||||
|
|
||||||
|
const { data, params }: PageProps = $props();
|
||||||
|
const latestEntry = $derived(data.entries[data.entries.length - 1]);
|
||||||
|
|
||||||
|
const previousEntries = $derived.by(() => {
|
||||||
|
const allEntries = data.entries ?? [];
|
||||||
|
|
||||||
|
return allEntries.slice(0, -1).map((entry, index) => {
|
||||||
|
const nextEntry = allEntries[index + 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
text: entry.story_text!,
|
||||||
|
possibleChoices: (entry.possible_choices ?? []).map((choice) => ({
|
||||||
|
id: choice.id,
|
||||||
|
text: choice.text,
|
||||||
|
isSelected: nextEntry?.generated_from_choice_id === choice.id
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="adventure-page">
|
||||||
|
<header class="adventure-page__header">
|
||||||
|
<p class="adventure-page__kicker">Choose your own adventure</p>
|
||||||
|
<h1 class="adventure-page__title">{data.title}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PreviousEntries entries={previousEntries} />
|
||||||
|
|
||||||
|
{#if latestEntry}
|
||||||
|
<LatestEntry
|
||||||
|
sourceText={latestEntry.story_text}
|
||||||
|
translationText={latestEntry.translation}
|
||||||
|
audioUrl={latestEntry.audio_url!}
|
||||||
|
nextStepsOptions={data.choices.map((choice) => ({
|
||||||
|
label: choice.text,
|
||||||
|
id: choice.id
|
||||||
|
}))}
|
||||||
|
adventureId={params.id}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.adventure-page {
|
||||||
|
max-width: 96rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-16) clamp(var(--space-3), 8vw, 7rem) var(--space-10)
|
||||||
|
clamp(var(--space-3), 5vw, 4.5rem);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventure-page__header {
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventure-page__kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adventure-page__title {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2rem, 1.75rem + 1.9vw, var(--text-display-lg));
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.adventure-page {
|
||||||
|
padding: var(--space-8) var(--space-3) var(--space-8);
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
387
frontend/src/routes/app/adventures/[id]/LatestEntry.svelte
Normal file
387
frontend/src/routes/app/adventures/[id]/LatestEntry.svelte
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import NextSteps from './NextSteps.svelte';
|
||||||
|
import { selectNextStep } from './selectNextStep.remote';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
adventureId: string;
|
||||||
|
sourceText: string | null | undefined;
|
||||||
|
translationText: string | null | undefined;
|
||||||
|
audioUrl: string;
|
||||||
|
nextStepsOptions: { label: string; id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { adventureId, sourceText, translationText, audioUrl, nextStepsOptions }: Props = $props();
|
||||||
|
|
||||||
|
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
|
||||||
|
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
|
||||||
|
|
||||||
|
let lastClickedParagraphIndex: number | null = $state(null);
|
||||||
|
let sourcePane = $state<HTMLDivElement | undefined>();
|
||||||
|
let translationPane = $state<HTMLDivElement | undefined>();
|
||||||
|
let suppressSourceScroll = $state(false);
|
||||||
|
let suppressTranslationScroll = $state(false);
|
||||||
|
let translationVisible = $state(false);
|
||||||
|
let translationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function toParagraphs(text: string | null | undefined): string[] {
|
||||||
|
return (text ?? '')
|
||||||
|
.split(/\n\s*\n/g)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScrollPosition(
|
||||||
|
origin: HTMLDivElement | undefined,
|
||||||
|
target: HTMLDivElement | undefined
|
||||||
|
) {
|
||||||
|
if (!origin || !target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originRange = origin.scrollHeight - origin.clientHeight;
|
||||||
|
const targetRange = target.scrollHeight - target.clientHeight;
|
||||||
|
const progress = originRange <= 0 ? 0 : origin.scrollTop / originRange;
|
||||||
|
|
||||||
|
target.scrollTop = targetRange <= 0 ? 0 : progress * targetRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSourceScroll() {
|
||||||
|
if (suppressSourceScroll) {
|
||||||
|
suppressSourceScroll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressTranslationScroll = true;
|
||||||
|
syncScrollPosition(sourcePane, translationPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTranslationScroll() {
|
||||||
|
if (suppressTranslationScroll) {
|
||||||
|
suppressTranslationScroll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressSourceScroll = true;
|
||||||
|
syncScrollPosition(translationPane, sourcePane);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTranslation() {
|
||||||
|
translationVisible = true;
|
||||||
|
if (translationTimer !== null) {
|
||||||
|
clearTimeout(translationTimer);
|
||||||
|
}
|
||||||
|
translationTimer = setTimeout(() => {
|
||||||
|
translationVisible = false;
|
||||||
|
translationTimer = null;
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleParagraphClicked(paragraphIndex: number) {
|
||||||
|
lastClickedParagraphIndex = paragraphIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNextStepSelect(optionId: string) {
|
||||||
|
const result = await selectNextStep({ adventureId, possibleChoiceId: optionId });
|
||||||
|
console.log({ result });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="latest-story" aria-label="Current story entry">
|
||||||
|
<header class="latest-story__header">
|
||||||
|
<div class="latest-story__title-group">
|
||||||
|
<p class="latest-story__kicker">Current entry</p>
|
||||||
|
<h2 class="latest-story__title">Now reading</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-dock" aria-label="Listening controls">
|
||||||
|
<p class="audio-dock__label">Listen</p>
|
||||||
|
<audio class="audio-dock__player" controls preload="metadata">
|
||||||
|
<source src={audioUrl} type="audio/wav" />
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="latest-entry">
|
||||||
|
<div class="pane source-pane">
|
||||||
|
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
||||||
|
{#each sourceParagraphs as paragraph, index (index)}
|
||||||
|
<p
|
||||||
|
class="paragraph"
|
||||||
|
class:active={lastClickedParagraphIndex === index}
|
||||||
|
data-paragraph-index={index}
|
||||||
|
data-language="source"
|
||||||
|
onclick={() => handleParagraphClicked(index)}
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane translation-pane" data-visible={translationVisible}>
|
||||||
|
<header class="translation-header">
|
||||||
|
<p class="translation-header__label">Translation</p>
|
||||||
|
<button class="dict-toggle" onclick={showTranslation}>
|
||||||
|
<span class="dict-toggle-label">Reveal for 20 seconds</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if translationVisible}
|
||||||
|
<div
|
||||||
|
class="latest-entry__pane-body"
|
||||||
|
bind:this={translationPane}
|
||||||
|
onscroll={handleTranslationScroll}
|
||||||
|
>
|
||||||
|
{#each translationParagraphs as paragraph, index (index)}
|
||||||
|
<p
|
||||||
|
class="paragraph"
|
||||||
|
class:active={lastClickedParagraphIndex === index}
|
||||||
|
data-paragraph-index={index}
|
||||||
|
data-language="translation"
|
||||||
|
onclick={() => handleParagraphClicked(index)}
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NextSteps options={nextStepsOptions} onSelect={handleNextStepSelect} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.latest-story {
|
||||||
|
--latest-entry-pane-height: clamp(20rem, 84vh, 48rem);
|
||||||
|
--latest-entry-pane-padding: var(--space-3);
|
||||||
|
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-story__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(16rem, 24rem);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding: var(--space-1) var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-story__kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-story__title {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.5rem, 1.32rem + 1vw, 2.2rem);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-dock {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--color-surface-container-lowest);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-dock__label {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-dock__player {
|
||||||
|
width: 100%;
|
||||||
|
height: 2.5rem;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: var(--latest-entry-pane-height);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pane {
|
||||||
|
background-color: var(--color-surface-container-lowest);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-pane {
|
||||||
|
transition: height var(--duration-normal) var(--ease-standard);
|
||||||
|
background-color: var(--color-surface-container);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-pane[data-visible='true'] {
|
||||||
|
height: var(--latest-entry-pane-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-pane[data-visible='false'] {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--latest-entry-pane-padding);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-header__label {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-2);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-fast) var(--ease-standard),
|
||||||
|
transform var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-toggle:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-toggle:active {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-toggle-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--latest-entry-pane-padding);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
animation: slideIn var(--duration-normal) var(--ease-standard) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar {
|
||||||
|
width: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--colour-yellow-300) 24%, transparent);
|
||||||
|
border: 0.1875rem solid transparent;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph {
|
||||||
|
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
text-wrap: pretty;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: background-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph.active {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph + .paragraph {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 64rem) {
|
||||||
|
.latest-story__header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-dock {
|
||||||
|
max-width: 26rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.latest-story {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
height: clamp(16rem, 62vh, 30rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
frontend/src/routes/app/adventures/[id]/NextSteps.svelte
Normal file
173
frontend/src/routes/app/adventures/[id]/NextSteps.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
options: { label: string; id: string }[];
|
||||||
|
onSelect: (optionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { options, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
const handleOptionSelect = async (optionId: string) => {
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
try {
|
||||||
|
await onSelect(optionId);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="next-steps" aria-label="Choose what happens next">
|
||||||
|
<header class="next-steps__header">
|
||||||
|
<p class="next-steps__kicker">Choose your path</p>
|
||||||
|
<h2 class="next-steps__title">What happens next?</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="next-steps__list">
|
||||||
|
{#each options as option, index (option.id)}
|
||||||
|
<li class="next-steps__item">
|
||||||
|
<button
|
||||||
|
class="next-steps__button"
|
||||||
|
onclick={() => handleOptionSelect(option.id)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<span class="next-steps__index" aria-hidden="true"
|
||||||
|
>{String(index + 1).padStart(2, '0')}</span
|
||||||
|
>
|
||||||
|
<span class="next-steps__label">{option.label}</span>
|
||||||
|
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.next-steps {
|
||||||
|
--next-steps-surface: var(--color-surface-container-low);
|
||||||
|
--next-steps-surface-hover: var(--color-surface-container-lowest);
|
||||||
|
|
||||||
|
max-width: 72ch;
|
||||||
|
margin: var(--space-6) auto 0;
|
||||||
|
padding: clamp(1rem, 0.9rem + 0.9vw, 1.8rem);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: var(--next-steps-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__title {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.35rem, 1.2rem + 0.9vw, 2rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: clamp(0.9rem, 0.8rem + 0.5vw, 1.2rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-container);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform var(--duration-fast) var(--ease-standard),
|
||||||
|
background-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__index {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__label {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.18rem);
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__meta {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button:hover:enabled {
|
||||||
|
background: var(--next-steps-surface-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button:hover:enabled .next-steps__meta,
|
||||||
|
.next-steps__button:focus-visible .next-steps__meta {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 42rem) {
|
||||||
|
.next-steps {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__button {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-steps__meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
frontend/src/routes/app/adventures/[id]/PreviousEntries.svelte
Normal file
220
frontend/src/routes/app/adventures/[id]/PreviousEntries.svelte
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
entries: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
possibleChoices: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { entries }: Props = $props();
|
||||||
|
|
||||||
|
function toParagraphs(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\n\s*\n/g)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<section class="previous-entries" aria-label="Previous story entries">
|
||||||
|
<header class="previous-entries__header">
|
||||||
|
<h2 class="previous-entries__title">Previous entries</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="previous-entries__list">
|
||||||
|
{#each entries as entry, index (entry.id)}
|
||||||
|
<li class="previous-entries__item">
|
||||||
|
<article class="entry-card" aria-label={`Entry ${index + 1}`}>
|
||||||
|
<header class="entry-card__header">
|
||||||
|
<p class="entry-card__index">Entry {String(index + 1).padStart(2, '0')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="entry-card__body">
|
||||||
|
{#each toParagraphs(entry.text) as paragraph, paragraphIndex (paragraphIndex)}
|
||||||
|
<p class="entry-card__paragraph">{paragraph}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if entry.possibleChoices.length > 0}
|
||||||
|
<footer class="entry-card__choices">
|
||||||
|
<p class="entry-card__choices-label">Possible choices</p>
|
||||||
|
<ul class="entry-card__choices-list" aria-label="Choices for this entry">
|
||||||
|
{#each entry.possibleChoices as choice, choiceIndex (choice.id)}
|
||||||
|
<li class="entry-card__choice" class:entry-card__choice--selected={choice.isSelected}>
|
||||||
|
<span class="entry-card__choice-index" aria-hidden="true"
|
||||||
|
>{String(choiceIndex + 1).padStart(2, '0')}</span
|
||||||
|
>
|
||||||
|
<span class="entry-card__choice-text">{choice.text}</span>
|
||||||
|
{#if choice.isSelected}
|
||||||
|
<span class="entry-card__choice-state">Selected</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.previous-entries {
|
||||||
|
--previous-surface: var(--color-surface-container-low);
|
||||||
|
--previous-surface-elevated: var(--color-surface-container-lowest);
|
||||||
|
max-width: 88rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-entries__header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-entries__title {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.3rem, 1.12rem + 0.85vw, 1.9rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-entries__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
padding: clamp(0.9rem, 0.8rem + 0.7vw, 1.4rem);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--previous-surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__header {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__index {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__body::-webkit-scrollbar {
|
||||||
|
width: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--colour-yellow-300) 26%, transparent);
|
||||||
|
border: 0.16rem solid transparent;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__paragraph {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: clamp(1rem, 0.97rem + 0.2vw, 1.12rem);
|
||||||
|
line-height: var(--leading-loose);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__paragraph + .entry-card__paragraph {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choices {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choices-label {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choices-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice-index {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 62%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice-text {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: color-mix(in srgb, var(--color-on-surface) 84%, transparent);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice-state {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice--selected {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card__choice--selected .entry-card__choice-text {
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 42rem) {
|
||||||
|
.previous-entries {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { command, getRequestEvent } from '$app/server';
|
||||||
|
import { recordDecisionApiAdventuresAdventureIdDecisionsPost } from '@client';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
|
||||||
|
const selectNextStepSchema = v.object({
|
||||||
|
adventureId: v.string(),
|
||||||
|
possibleChoiceId: v.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectNextStep = command(
|
||||||
|
selectNextStepSchema,
|
||||||
|
async ({ adventureId, possibleChoiceId }) => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
const { error, response, data } = await recordDecisionApiAdventuresAdventureIdDecisionsPost({
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${locals.authToken}`
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
adventure_id: adventureId
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
choice_id: possibleChoiceId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error recording decision:', error);
|
||||||
|
throw new Error('Failed to record decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -9,8 +9,11 @@
|
||||||
|
|
||||||
const fmt = (iso: string) =>
|
const fmt = (iso: string) =>
|
||||||
new Intl.DateTimeFormat('en-GB', {
|
new Intl.DateTimeFormat('en-GB', {
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit'
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
}).format(new Date(iso));
|
}).format(new Date(iso));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -32,13 +35,13 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--colour-outline-variant) 30%, transparent);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-row + .job-row {
|
.job-row + .job-row {
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--colour-outline-variant) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-link {
|
.job-link {
|
||||||
|
|
@ -73,12 +76,12 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge[data-status="completed"] {
|
.status-badge[data-status='completed'] {
|
||||||
background-color: var(--color-primary-container);
|
background-color: var(--color-primary-container);
|
||||||
color: var(--color-on-primary-container);
|
color: var(--color-on-primary-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge[data-status="failed"] {
|
.status-badge[data-status='failed'] {
|
||||||
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
|
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
|
||||||
color: #b3261e;
|
color: #b3261e;
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +106,7 @@
|
||||||
.job-arrow {
|
.job-arrow {
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: var(--text-label-lg);
|
font-size: var(--text-label-lg);
|
||||||
color: var(--color-outline);
|
color: var(--colour-outline);
|
||||||
transition: color var(--duration-fast) var(--ease-standard);
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const actions = {
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
const { response, data } = await loginApiAuthLoginPost({
|
const { response, data, error } = await loginApiAuthLoginPost({
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''
|
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''
|
||||||
|
|
@ -26,6 +26,12 @@ export const actions = {
|
||||||
body: { email, password }
|
body: { email, password }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error logging in:`, { error });
|
||||||
|
cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 200 && data) {
|
if (response.status === 200 && data) {
|
||||||
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
|
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue