Compare commits

..

No commits in common. "fac5d2622010005ef0b32bc7e1e0d468abe946c0" and "48bbcac9a6661d1e08ea4fce11c7cc7de964fb29" have entirely different histories.

25 changed files with 25 additions and 1446 deletions

View file

@ -1,42 +0,0 @@
"""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")

View file

@ -144,7 +144,6 @@ 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.
@ -158,7 +157,6 @@ 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
@ -186,7 +184,6 @@ 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(
@ -225,8 +222,7 @@ class AdventureService:
) )
voice = self.gemini_client.get_voice_by_language(adventure.language) voice = self.gemini_client.get_voice_by_language(adventure.language)
story_text_with_tag = "[like a dungeons and dragons gamemaster] " + story_text wav_bytes = await self.gemini_client.generate_audio(story_text, voice)
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(

View file

@ -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). \n" f"- Part 3: GM notes to your future self (hidden from the player). "
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,7 +96,6 @@ 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.
@ -111,22 +110,8 @@ 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:
# Find the player's decision for this entry messages.append({"role": "user", "content": chosen_label})
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

View file

@ -82,9 +82,6 @@ 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):
@ -145,6 +142,3 @@ 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)
)

View file

@ -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, user_id: uuid.UUID adventure_id: uuid.UUID, entry_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, user_id) await _make_service(db).run_entry_pipeline(adventure_id, entry_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), user_id _run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id)
) )
) )
return _to_adventure_response(adventure) return _to_adventure_response(adventure)
@ -375,7 +375,6 @@ 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(

View file

@ -1,145 +0,0 @@
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,
)

View file

@ -1,5 +1,4 @@
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
@ -9,7 +8,6 @@ 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)

View file

@ -1,5 +1,3 @@
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
@ -7,37 +5,11 @@ 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,
@ -56,4 +28,3 @@ 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)

View file

@ -34,11 +34,8 @@ 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}
@ -48,7 +45,6 @@ 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

View file

@ -82,19 +82,6 @@
--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;
@ -139,7 +126,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 */
@ -318,7 +305,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);
} }
@ -639,8 +626,3 @@ 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

View file

@ -192,144 +192,6 @@ 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
*/ */
@ -3031,36 +2893,6 @@ 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;
@ -3173,34 +3005,6 @@ 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: {

View file

@ -32,7 +32,6 @@ 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;

View file

@ -5,6 +5,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
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

View file

@ -23,10 +23,8 @@
{#each adventures as adventure (adventure.id)} {#each adventures as adventure (adventure.id)}
<div class="adventure-card"> <div class="adventure-card">
<a href={resolve('/app/adventures/[id]', { id: adventure.id })}>
<h2>{adventure.title}</h2> <h2>{adventure.title}</h2>
<p>{adventure.description}</p> <p>{adventure.description}</p>
</a>
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -1,29 +0,0 @@
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
};
};

View file

@ -1,92 +0,0 @@
<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>

View file

@ -1,387 +0,0 @@
<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>

View file

@ -1,173 +0,0 @@
<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>

View file

@ -1,220 +0,0 @@
<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>

View file

@ -1,33 +0,0 @@
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;
}
);

View file

@ -9,11 +9,8 @@
const fmt = (iso: string) => const fmt = (iso: string) =>
new Intl.DateTimeFormat('en-GB', { new Intl.DateTimeFormat('en-GB', {
year: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
month: 'short', hour: '2-digit', minute: '2-digit'
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso)); }).format(new Date(iso));
</script> </script>
@ -35,13 +32,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(--colour-outline-variant) 30%, transparent); border: 1px solid color-mix(in srgb, var(--color-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(--colour-outline-variant) 30%, transparent); border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
} }
.job-link { .job-link {
@ -76,12 +73,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;
} }
@ -106,7 +103,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(--colour-outline); color: var(--color-outline);
transition: color var(--duration-fast) var(--ease-standard); transition: color var(--duration-fast) var(--ease-standard);
} }

View file

@ -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, error } = await loginApiAuthLoginPost({ const { response, data } = 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,12 +26,6 @@ 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: '/',